From 7f51dfd6ac99c0b95e5f0b75fada3b5337556b38 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 17 Aug 2026 20:11:36 +0200 Subject: [PATCH] Isolate Eve browser lowering per host --- packages/eve-browser-lowering/dist/index.js | 168 +++-- .../docs/host-authority.md | 58 ++ .../eve-browser-lowering/package-lock.json | 585 ++++++++++++++++++ packages/eve-browser-lowering/package.json | 6 +- packages/eve-browser-lowering/src/index.ts | 191 ++++-- .../test/host-isolation.test.mjs | 154 +++++ 6 files changed, 1078 insertions(+), 84 deletions(-) create mode 100644 packages/eve-browser-lowering/docs/host-authority.md create mode 100644 packages/eve-browser-lowering/test/host-isolation.test.mjs diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 5f9d6c4..200d438 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -122,18 +122,27 @@ const defaultStyleTokens = { "--font-title": "\"Montserrat\", \"Zen Kaku Gothic New\", \"M PLUS 1\", \"Ubuntu Sans\", system-ui, sans-serif", "--font-mono": "\"Ubuntu Sans Mono\", \"M PLUS 1 Code\", \"Cascadia Mono\", Consolas, monospace", }; -let activeFontStylesheet; -let currentSurfaceStyles = normalizeSurfaceStyles(undefined); -let currentSurfaceDocument; -let currentOptions = {}; +const loadedFontStylesheets = new Map(); const activeSurfaceBindings = new WeakMap(); +const eveBrowserRenderContext = Symbol("eve.browser.render-context"); +function scopeEveBrowserOptions(surface, options) { + return { + ...options, + [eveBrowserRenderContext]: { + surface, + styles: normalizeSurfaceStyles(surface.surface?.styles), + elements: new WeakMap(), + }, + }; +} +function renderContext(options) { + return options[eveBrowserRenderContext]; +} export function renderEveSurface(surface, host, options = {}) { activeSurfaceBindings.get(host)?.dispose(); - currentSurfaceDocument = surface; - currentOptions = options; - currentSurfaceStyles = normalizeSurfaceStyles(surface.surface?.styles); - globalThis.__eveCurrentMesh = surface.mesh; - applyEveSurfaceStyles(surface.surface?.styles, options.body ?? document.body); + const scopedOptions = scopeEveBrowserOptions(surface, options); + applyEveSurfaceStyles(surface.surface?.styles, options.body ?? host); + host.dataset.provider = surface.providerId || options.provider?.providerId || ""; if (options.body) { options.body.dataset.provider = surface.providerId || options.provider?.providerId || ""; } @@ -141,9 +150,9 @@ export function renderEveSurface(surface, host, options = {}) { options.statusElement.textContent = `${surface.title || surface.surface?.title || "surface"}${options.source ? ` (${options.source})` : ""}`; } if (surface.surface?.root) { - host.replaceChildren(renderEveComponent(surface.surface.root, options)); + host.replaceChildren(renderEveComponent(surface.surface.root, scopedOptions)); if (options.stateBindingResolver) { - const controller = new EveSurfaceBindingController(surface, host, options); + const controller = new EveSurfaceBindingController(surface, host, scopedOptions); activeSurfaceBindings.set(host, controller); void controller.start(); } @@ -173,6 +182,8 @@ class EveSurfaceBindingController { options; disposed = false; unsubscribers = []; + pendingComponents = new Set(); + projectionQueued = false; constructor(surface, host, options) { this.surface = surface; this.host = host; @@ -183,6 +194,7 @@ class EveSurfaceBindingController { const root = this.surface.surface?.root; if (!resolver || !root) return; + const hydratedComponents = new Set(); for (const component of walkEveComponents(root)) { for (const binding of component.stateBindings || []) { if (this.disposed) @@ -194,32 +206,102 @@ class EveSurfaceBindingController { if (this.disposed) return; applyEveStateBindingValue(component, binding, value); - this.renderCanonicalProjection(); + this.queueComponentProjection(component); }; - apply(await handle.latest()); + applyEveStateBindingValue(component, binding, await handle.latest()); + hydratedComponents.add(component); if (this.disposed) return; this.unsubscribers.push(handle.watch(apply)); } } + for (const component of hydratedComponents) { + if (this.disposed) + return; + this.renderComponentProjection(component); + } } dispose() { this.disposed = true; + this.pendingComponents.clear(); for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe(); } - renderCanonicalProjection() { - const root = this.surface.surface?.root; - if (root) - this.host.replaceChildren(renderEveComponent(root, this.options)); + queueComponentProjection(component) { + this.pendingComponents.add(component); + if (this.projectionQueued) + return; + this.projectionQueued = true; + queueMicrotask(() => { + this.projectionQueued = false; + if (this.disposed) + return; + for (const pending of this.pendingComponents) + this.renderComponentProjection(pending); + this.pendingComponents.clear(); + }); } + renderComponentProjection(component) { + const current = renderContext(this.options)?.elements.get(component); + if (!current?.isConnected) + return; + replaceElementPreservingInteraction(current, renderEveComponent(component, this.options), this.host); + } +} +function replaceElementPreservingInteraction(current, replacement, host) { + const active = current.ownerDocument.activeElement; + const activeElement = active instanceof HTMLElement && current.contains(active) ? active : undefined; + const activePath = activeElement ? elementPath(current, activeElement) : undefined; + const selection = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement + ? { start: activeElement.selectionStart, end: activeElement.selectionEnd, direction: activeElement.selectionDirection } + : undefined; + const hostScroll = { left: host.scrollLeft, top: host.scrollTop }; + current.replaceWith(replacement); + host.scrollLeft = hostScroll.left; + host.scrollTop = hostScroll.top; + if (!activePath) + return; + const nextActive = elementAtPath(replacement, activePath); + nextActive?.focus({ preventScroll: true }); + if (selection && (nextActive instanceof HTMLInputElement || nextActive instanceof HTMLTextAreaElement)) { + nextActive.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined); + } +} +function elementPath(root, target) { + if (root === target) + return []; + const path = []; + let current = target; + while (current && current !== root) { + const parent = current.parentElement; + if (!parent) + return undefined; + path.unshift(Array.prototype.indexOf.call(parent.children, current)); + current = parent; + } + return current === root ? path : undefined; +} +function elementAtPath(root, path) { + let current = root; + for (const index of path) { + const child = current.children.item(index); + if (!(child instanceof HTMLElement)) + return undefined; + current = child; + } + return current instanceof HTMLElement ? current : undefined; } function* walkEveComponents(root) { yield root; for (const child of root.children || []) yield* walkEveComponents(child); } -export function renderEveComponent(node, options = currentOptions) { +export function renderEveComponent(node, options = {}) { + const element = renderEveComponentProjection(node, options); + renderContext(options)?.elements.set(node, element); + return element; +} +function renderEveComponentProjection(node, options) { const kind = node.kind || "panel"; const props = objectProps(node.props); const layout = objectProps(node.layout); @@ -300,8 +382,8 @@ export function renderEveComponent(node, options = currentOptions) { if (adapter?.renderComponent) { return adapter.renderComponent(node, props, layout, style, options, { applyGeneratedLayout, - providerId: currentSurfaceDocument?.providerId || options.provider?.providerId, - resolveAssetUrl, + providerId: renderContext(options)?.surface.providerId || options.provider?.providerId, + resolveAssetUrl: uri => resolveAssetUrl(uri, options), }); } if (kind === "world.scene3d" || kind === "world.scene2d") { @@ -309,7 +391,7 @@ export function renderEveComponent(node, options = currentOptions) { } if (kind === "image.background") { const view = el("div", "cultui-background"); - const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, "")); + const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options); if (src) view.style.backgroundImage = `url("${src}")`; view.setAttribute("aria-label", stringProp(props.label, "background")); @@ -318,7 +400,7 @@ export function renderEveComponent(node, options = currentOptions) { if (kind === "image.sprite") { const figure = el("figure", `cultui-sprite ${stringProp(props.slot, "center")}`); assignId(figure, node); - const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, "")); + const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options); if (src) { const image = el("img"); image.src = src; @@ -330,7 +412,7 @@ export function renderEveComponent(node, options = currentOptions) { if (kind === "avatar") { const card = el("figure", "cultui-avatar-card"); assignId(card, node); - const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, "")); + const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options); if (src) { const image = el("img"); image.src = src; @@ -529,7 +611,7 @@ export function renderEveComponent(node, options = currentOptions) { const figure = el("figure", "cultui-image-preview"); assignId(figure, node); const frame = el("div", "cultui-image-frame"); - const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, "")); + const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options); if (src) { const image = el("img"); image.src = src; @@ -848,7 +930,7 @@ function renderWorldScene(node, props, layout, style, options) { function emitWorldCommand(command, action, node, options) { void options.commandSink?.(createWorldActionIntent(command, action, options), node); } -export function createWorldActionIntent(command, action, options = currentOptions) { +export function createWorldActionIntent(command, action, options = {}) { return createEveCommandIntent(command, { action }, options); } function worldDirectionForKey(key) { @@ -1060,7 +1142,7 @@ function renderInventoryItem(node, props, layout, style, options) { function hasInventoryDropCommand(target) { return Object.entries(target).some(([key, value]) => (key === "dropCommand" || key.startsWith("dropCommand.")) && typeof value === "string" && value.length > 0); } -export function createInventoryDropIntent(source, target, destinationX, destinationY, options = currentOptions) { +export function createInventoryDropIntent(source, target, destinationX, destinationY, options = {}) { const sourceKind = firstString(source.sourceKind, source.source, ""); const command = firstString(target[`dropCommand.${sourceKind}`], target.dropCommand, ""); if (!sourceKind || !command) @@ -1395,10 +1477,11 @@ export function applyEveSurfaceStyles(styles, body = document.body) { body.dataset.pixelArt = normalized.tokens.pixelArt || normalized.tokens.imageRendering === "pixelated" ? "true" : "false"; body.dataset.scanlines = normalized.tokens.scanlineOverlay ? "true" : "false"; } -export function createEveCommandIntent(commandId, props = {}, options = currentOptions) { +export function createEveCommandIntent(commandId, props = {}, options = {}) { const action = objectProps(props.action); - const providerId = options.provider?.providerId || currentSurfaceDocument?.providerId || "surface unknown"; - const surfaceId = options.activeSurfaceId || currentSurfaceDocument?.surface?.id || options.provider?.surfaces?.[0]?.surfaceId || providerId; + const surface = renderContext(options)?.surface; + const providerId = options.provider?.providerId || surface?.providerId || "surface unknown"; + const surfaceId = options.activeSurfaceId || surface?.surface?.id || options.provider?.surfaces?.[0]?.surfaceId || providerId; const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId); const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target); const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema); @@ -1456,7 +1539,7 @@ function wireCommand(element, node, commandId, props, options) { }); } function renderSlider(props, children, options) { - const anatomy = resolveSliderAnatomy(props, children); + const anatomy = resolveSliderAnatomy(props, children, options); const box = objectProps(anatomy.find(part => part.kind === "control.box")?.props); const parts = anatomy.filter(part => part.kind === "control.part"); const min = Number(props.min ?? 0); @@ -1507,9 +1590,11 @@ function renderSlider(props, children, options) { }); return slider; } -function resolveSliderAnatomy(props, children) { +function resolveSliderAnatomy(props, children, options) { const skinName = typeof props.skin === "string" ? props.skin : ""; - const skinChildren = skinName ? currentSurfaceStyles.controlSkins[skinName]?.children || [] : []; + const skinChildren = skinName + ? renderContext(options)?.styles.controlSkins[skinName]?.children || [] + : []; const anatomy = [...skinChildren, ...children]; if (anatomy.length) return anatomy; @@ -1540,27 +1625,26 @@ function normalizeSurfaceStyles(styles) { controlSkins: objectProps(styles?.controlSkins), }; } -function resolveAssetUrl(uri) { +function resolveAssetUrl(uri, options) { if (!uri) return ""; - const resolver = currentOptions.assetUrlResolver; + const surface = renderContext(options)?.surface; + const resolver = options.assetUrlResolver; if (resolver) - return resolver(uri, currentSurfaceDocument); + return resolver(uri, surface); if (/^(https?:|data:|blob:)/i.test(uri)) return uri; - if (currentOptions.assetBaseUrl && uri.startsWith("/")) { - return `${currentOptions.assetBaseUrl.replace(/\/+$/, "")}${uri}`; + if (options.assetBaseUrl && uri.startsWith("/")) { + return `${options.assetBaseUrl.replace(/\/+$/, "")}${uri}`; } return uri; } function loadFontStylesheet(href) { const normalized = typeof href === "string" ? href.trim() : ""; - if (activeFontStylesheet?.dataset.href === normalized) - return; - activeFontStylesheet?.remove(); - activeFontStylesheet = undefined; if (!normalized) return; + if (loadedFontStylesheets.has(normalized)) + return; const preconnect = document.createElement("link"); preconnect.rel = "preconnect"; preconnect.href = "https://fonts.gstatic.com"; @@ -1571,7 +1655,7 @@ function loadFontStylesheet(href) { link.href = normalized; link.dataset.href = normalized; document.head.append(link); - activeFontStylesheet = link; + loadedFontStylesheets.set(normalized, link); } function textClassName(kind, props, node) { const classes = []; diff --git a/packages/eve-browser-lowering/docs/host-authority.md b/packages/eve-browser-lowering/docs/host-authority.md new file mode 100644 index 0000000..c733365 --- /dev/null +++ b/packages/eve-browser-lowering/docs/host-authority.md @@ -0,0 +1,58 @@ +# Browser Lowering Host Authority + +Owner: + +- one rendered host owns its surface document, lowering options, style tokens, + component-to-element index, and state-binding subscriptions; +- the provider owns the surface graph and command meaning; +- the browser lowerer owns only DOM projection and interaction forwarding. + +Inputs: + +- one `EveSurfaceDocument`; +- one host element; +- host-scoped lowering options, plugin adapters, asset resolver, and binding + resolver; +- canonical state-binding values. + +Outputs: + +- DOM beneath that host; +- typed command intents carrying that host's provider and surface identity; +- host-local style variables and provider diagnostics. + +Derived state: + +- DOM nodes, focus restoration data, normalized styles, and component indexes + are projections of the owning surface; +- loaded font stylesheets are a document resource cache, not active-surface + state. + +Forbidden writers: + +- another host may not replace the active surface, options, styles, assets, or + command identity used by this host; +- binding updates may not rebuild an unrelated host or mutate provider state; +- module globals may not decide which surface a command or asset belongs to. + +Shared paths: + +- initial render, embedded render, plugin render, binding updates, command + creation, and asset resolution all receive the same host-scoped context; +- initial binding hydration and live binding watches use the same component + projection replacement primitive. + +Cut line: + +- delete module-global current surface/options/styles; +- remove the global current-mesh side channel; +- replace whole-host binding rerenders with indexed component-subtree updates; +- preserve focus, selection, and host scroll across a bound component update. + +Verification: + +- two hosts in one document retain distinct provider commands, asset routes, + and control skins after either host renders or updates; +- a bound leaf update does not replace either host root; +- focused input selection and host scroll survive a binding update; +- disposing or rerendering one host cannot unsubscribe the other. diff --git a/packages/eve-browser-lowering/package-lock.json b/packages/eve-browser-lowering/package-lock.json index 1ad3a9c..10c1e35 100644 --- a/packages/eve-browser-lowering/package-lock.json +++ b/packages/eve-browser-lowering/package-lock.json @@ -8,9 +8,512 @@ "name": "@gamecult/eve-browser-lowering", "version": "0.1.0", "devDependencies": { + "@types/jsdom": "^30.0.0", + "jsdom": "^30.0.1", "typescript": "^5.6.3" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@types/jsdom": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz", + "integrity": "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^8.9.0" + } + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -24,6 +527,88 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.10.0.tgz", + "integrity": "sha512-ibvdovq3nCFs8Msrd95BW+zUOq+aOVbT+wpHUoPWhztbHEoPc6oof51iFDB6Es8lTKvNvVW9jNSAB8dwrKTMGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/packages/eve-browser-lowering/package.json b/packages/eve-browser-lowering/package.json index e56ce93..c0eccf5 100644 --- a/packages/eve-browser-lowering/package.json +++ b/packages/eve-browser-lowering/package.json @@ -9,10 +9,12 @@ "styles.css" ], "scripts": { - "build": "C:\\Progra~1\\nodejs\\node.exe ./node_modules/typescript/bin/tsc -p tsconfig.json", - "test": "C:\\Progra~1\\nodejs\\node.exe --test test/*.test.mjs" + "build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json", + "test": "node ./node_modules/typescript/bin/tsc -p tsconfig.json && node --test test/*.test.mjs" }, "devDependencies": { + "@types/jsdom": "^30.0.0", + "jsdom": "^30.0.1", "typescript": "^5.6.3" } } diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index c4cb66b..541f72f 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -297,10 +297,7 @@ const defaultStyleTokens: Record = { "--font-mono": "\"Ubuntu Sans Mono\", \"M PLUS 1 Code\", \"Cascadia Mono\", Consolas, monospace", }; -let activeFontStylesheet: HTMLLinkElement | undefined; -let currentSurfaceStyles: NormalizedStyles = normalizeSurfaceStyles(undefined); -let currentSurfaceDocument: EveSurfaceDocument | undefined; -let currentOptions: EveBrowserLoweringOptions = {}; +const loadedFontStylesheets = new Map(); const activeSurfaceBindings = new WeakMap(); interface NormalizedStyles { @@ -309,17 +306,44 @@ interface NormalizedStyles { controlSkins: Record; } +interface EveBrowserRenderContext { + readonly surface: EveSurfaceDocument; + readonly styles: NormalizedStyles; + readonly elements: WeakMap; +} + +const eveBrowserRenderContext = Symbol("eve.browser.render-context"); +type EveBrowserScopedOptions = EveBrowserLoweringOptions & { + [eveBrowserRenderContext]?: EveBrowserRenderContext; +}; + +function scopeEveBrowserOptions( + surface: EveSurfaceDocument, + options: EveBrowserLoweringOptions, +): EveBrowserScopedOptions { + return { + ...options, + [eveBrowserRenderContext]: { + surface, + styles: normalizeSurfaceStyles(surface.surface?.styles), + elements: new WeakMap(), + }, + }; +} + +function renderContext(options: EveBrowserLoweringOptions): EveBrowserRenderContext | undefined { + return (options as EveBrowserScopedOptions)[eveBrowserRenderContext]; +} + export function renderEveSurface( surface: EveSurfaceDocument, host: HTMLElement, options: EveBrowserLoweringOptions = {}, ): HTMLElement { activeSurfaceBindings.get(host)?.dispose(); - currentSurfaceDocument = surface; - currentOptions = options; - currentSurfaceStyles = normalizeSurfaceStyles(surface.surface?.styles); - (globalThis as { __eveCurrentMesh?: unknown }).__eveCurrentMesh = surface.mesh; - applyEveSurfaceStyles(surface.surface?.styles, options.body ?? document.body); + const scopedOptions = scopeEveBrowserOptions(surface, options); + applyEveSurfaceStyles(surface.surface?.styles, options.body ?? host); + host.dataset.provider = surface.providerId || options.provider?.providerId || ""; if (options.body) { options.body.dataset.provider = surface.providerId || options.provider?.providerId || ""; } @@ -327,9 +351,9 @@ export function renderEveSurface( options.statusElement.textContent = `${surface.title || surface.surface?.title || "surface"}${options.source ? ` (${options.source})` : ""}`; } if (surface.surface?.root) { - host.replaceChildren(renderEveComponent(surface.surface.root, options)); + host.replaceChildren(renderEveComponent(surface.surface.root, scopedOptions)); if (options.stateBindingResolver) { - const controller = new EveSurfaceBindingController(surface, host, options); + const controller = new EveSurfaceBindingController(surface, host, scopedOptions); activeSurfaceBindings.set(host, controller); void controller.start(); } @@ -359,6 +383,8 @@ export function applyEveStateBindingValue( class EveSurfaceBindingController { private disposed = false; private readonly unsubscribers: Array<() => void> = []; + private readonly pendingComponents = new Set(); + private projectionQueued = false; constructor( private readonly surface: EveSurfaceDocument, @@ -370,6 +396,7 @@ class EveSurfaceBindingController { const resolver = this.options.stateBindingResolver; const root = this.surface.surface?.root; if (!resolver || !root) return; + const hydratedComponents = new Set(); for (const component of walkEveComponents(root)) { for (const binding of component.stateBindings || []) { if (this.disposed) return; @@ -378,24 +405,93 @@ class EveSurfaceBindingController { const apply = (value: unknown) => { if (this.disposed) return; applyEveStateBindingValue(component, binding, value); - this.renderCanonicalProjection(); + this.queueComponentProjection(component); }; - apply(await handle.latest()); + applyEveStateBindingValue(component, binding, await handle.latest()); + hydratedComponents.add(component); if (this.disposed) return; this.unsubscribers.push(handle.watch(apply)); } } + for (const component of hydratedComponents) { + if (this.disposed) return; + this.renderComponentProjection(component); + } } dispose(): void { this.disposed = true; + this.pendingComponents.clear(); for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe(); } - private renderCanonicalProjection(): void { - const root = this.surface.surface?.root; - if (root) this.host.replaceChildren(renderEveComponent(root, this.options)); + private queueComponentProjection(component: EveSurfaceComponent): void { + this.pendingComponents.add(component); + if (this.projectionQueued) return; + this.projectionQueued = true; + queueMicrotask(() => { + this.projectionQueued = false; + if (this.disposed) return; + for (const pending of this.pendingComponents) this.renderComponentProjection(pending); + this.pendingComponents.clear(); + }); } + + private renderComponentProjection(component: EveSurfaceComponent): void { + const current = renderContext(this.options)?.elements.get(component); + if (!current?.isConnected) return; + replaceElementPreservingInteraction( + current, + renderEveComponent(component, this.options), + this.host, + ); + } +} + +function replaceElementPreservingInteraction( + current: HTMLElement, + replacement: HTMLElement, + host: HTMLElement, +): void { + const active = current.ownerDocument.activeElement; + const activeElement = active instanceof HTMLElement && current.contains(active) ? active : undefined; + const activePath = activeElement ? elementPath(current, activeElement) : undefined; + const selection = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement + ? { start: activeElement.selectionStart, end: activeElement.selectionEnd, direction: activeElement.selectionDirection } + : undefined; + const hostScroll = { left: host.scrollLeft, top: host.scrollTop }; + current.replaceWith(replacement); + host.scrollLeft = hostScroll.left; + host.scrollTop = hostScroll.top; + if (!activePath) return; + const nextActive = elementAtPath(replacement, activePath); + nextActive?.focus({ preventScroll: true }); + if (selection && (nextActive instanceof HTMLInputElement || nextActive instanceof HTMLTextAreaElement)) { + nextActive.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined); + } +} + +function elementPath(root: HTMLElement, target: HTMLElement): number[] | undefined { + if (root === target) return []; + const path: number[] = []; + let current: HTMLElement | null = target; + while (current && current !== root) { + const parent: HTMLElement | null = current.parentElement; + if (!parent) return undefined; + path.unshift(Array.prototype.indexOf.call(parent.children, current)); + current = parent; + } + return current === root ? path : undefined; +} + +function elementAtPath(root: HTMLElement, path: readonly number[]): HTMLElement | undefined { + let current: Element = root; + for (const index of path) { + const child = current.children.item(index); + if (!(child instanceof HTMLElement)) return undefined; + current = child; + } + return current instanceof HTMLElement ? current : undefined; } function* walkEveComponents(root: EveSurfaceComponent): Generator { @@ -405,7 +501,16 @@ function* walkEveComponents(root: EveSurfaceComponent): Generator resolveAssetUrl(uri, options), }); } @@ -496,7 +601,7 @@ export function renderEveComponent( if (kind === "image.background") { const view = el("div", "cultui-background"); - const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, "")); + const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options); if (src) view.style.backgroundImage = `url("${src}")`; view.setAttribute("aria-label", stringProp(props.label, "background")); return view; @@ -505,7 +610,7 @@ export function renderEveComponent( if (kind === "image.sprite") { const figure = el("figure", `cultui-sprite ${stringProp(props.slot, "center")}`); assignId(figure, node); - const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, "")); + const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options); if (src) { const image = el("img") as HTMLImageElement; image.src = src; @@ -518,7 +623,7 @@ export function renderEveComponent( if (kind === "avatar") { const card = el("figure", "cultui-avatar-card"); assignId(card, node); - const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, "")); + const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options); if (src) { const image = el("img") as HTMLImageElement; image.src = src; @@ -707,7 +812,7 @@ export function renderEveComponent( const figure = el("figure", "cultui-image-preview"); assignId(figure, node); const frame = el("div", "cultui-image-frame"); - const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, "")); + const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options); if (src) { const image = el("img") as HTMLImageElement; image.src = src; @@ -1027,7 +1132,7 @@ function emitWorldCommand( export function createWorldActionIntent( command: string, action: Record, - options: EveBrowserLoweringOptions = currentOptions, + options: EveBrowserLoweringOptions = {}, ): EveCommandIntent { return createEveCommandIntent(command, { action }, options); } @@ -1261,7 +1366,7 @@ export function createInventoryDropIntent( target: Record, destinationX: number, destinationY: number, - options: EveBrowserLoweringOptions = currentOptions, + options: EveBrowserLoweringOptions = {}, ): EveCommandIntent | undefined { const sourceKind = firstString(source.sourceKind, source.source, ""); const command = firstString(target[`dropCommand.${sourceKind}`], target.dropCommand, ""); @@ -1628,11 +1733,12 @@ export function applyEveSurfaceStyles(styles: EveSurfaceStyles | undefined, body export function createEveCommandIntent( commandId: string, props: Record = {}, - options: EveBrowserLoweringOptions = currentOptions, + options: EveBrowserLoweringOptions = {}, ): EveCommandIntent { const action = objectProps(props.action); - const providerId = options.provider?.providerId || currentSurfaceDocument?.providerId || "surface unknown"; - const surfaceId = options.activeSurfaceId || currentSurfaceDocument?.surface?.id || options.provider?.surfaces?.[0]?.surfaceId || providerId; + const surface = renderContext(options)?.surface; + const providerId = options.provider?.providerId || surface?.providerId || "surface unknown"; + const surfaceId = options.activeSurfaceId || surface?.surface?.id || options.provider?.surfaces?.[0]?.surfaceId || providerId; const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId); const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target); const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema); @@ -1715,7 +1821,7 @@ function renderSlider( children: EveSurfaceComponent[], options: EveBrowserLoweringOptions, ): HTMLElement { - const anatomy = resolveSliderAnatomy(props, children); + const anatomy = resolveSliderAnatomy(props, children, options); const box = objectProps(anatomy.find(part => part.kind === "control.box")?.props); const parts = anatomy.filter(part => part.kind === "control.part"); const min = Number(props.min ?? 0); @@ -1764,9 +1870,15 @@ function renderSlider( return slider; } -function resolveSliderAnatomy(props: Record, children: EveSurfaceComponent[]): EveSurfaceComponent[] { +function resolveSliderAnatomy( + props: Record, + children: EveSurfaceComponent[], + options: EveBrowserLoweringOptions, +): EveSurfaceComponent[] { const skinName = typeof props.skin === "string" ? props.skin : ""; - const skinChildren = skinName ? currentSurfaceStyles.controlSkins[skinName]?.children || [] : []; + const skinChildren = skinName + ? renderContext(options)?.styles.controlSkins[skinName]?.children || [] + : []; const anatomy = [...skinChildren, ...children]; if (anatomy.length) return anatomy; return [ @@ -1795,23 +1907,22 @@ function normalizeSurfaceStyles(styles: EveSurfaceStyles | undefined): Normalize }; } -function resolveAssetUrl(uri: string): string { +function resolveAssetUrl(uri: string, options: EveBrowserLoweringOptions): string { if (!uri) return ""; - const resolver = currentOptions.assetUrlResolver; - if (resolver) return resolver(uri, currentSurfaceDocument); + const surface = renderContext(options)?.surface; + const resolver = options.assetUrlResolver; + if (resolver) return resolver(uri, surface); if (/^(https?:|data:|blob:)/i.test(uri)) return uri; - if (currentOptions.assetBaseUrl && uri.startsWith("/")) { - return `${currentOptions.assetBaseUrl.replace(/\/+$/, "")}${uri}`; + if (options.assetBaseUrl && uri.startsWith("/")) { + return `${options.assetBaseUrl.replace(/\/+$/, "")}${uri}`; } return uri; } function loadFontStylesheet(href: unknown): void { const normalized = typeof href === "string" ? href.trim() : ""; - if (activeFontStylesheet?.dataset.href === normalized) return; - activeFontStylesheet?.remove(); - activeFontStylesheet = undefined; if (!normalized) return; + if (loadedFontStylesheets.has(normalized)) return; const preconnect = document.createElement("link"); preconnect.rel = "preconnect"; preconnect.href = "https://fonts.gstatic.com"; @@ -1822,7 +1933,7 @@ function loadFontStylesheet(href: unknown): void { link.href = normalized; link.dataset.href = normalized; document.head.append(link); - activeFontStylesheet = link; + loadedFontStylesheets.set(normalized, link); } function textClassName(kind: string, props: Record, node: EveSurfaceComponent): string { diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs new file mode 100644 index 0000000..601fd32 --- /dev/null +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { JSDOM } from "jsdom"; +import { renderEveSurface } from "../dist/index.js"; + +function installDom() { + const dom = new JSDOM("
", { + url: "https://eve.test/", + pretendToBeVisual: true, + }); + const names = [ + "document", + "window", + "Element", + "HTMLElement", + "HTMLButtonElement", + "HTMLInputElement", + "HTMLTextAreaElement", + "HTMLImageElement", + ]; + const previous = new Map(names.map(name => [name, globalThis[name]])); + for (const name of names) globalThis[name] = dom.window[name]; + return () => { + for (const [name, value] of previous) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; + } + dom.window.close(); + }; +} + +function testInputAdapter(onRender = () => {}) { + return { + pluginId: "test.input", + componentKinds: ["test.input"], + renderComponent(_node, props) { + onRender(); + const input = document.createElement("input"); + input.type = "text"; + input.value = String(props.value ?? ""); + return input; + }, + }; +} + +function surface(providerId, imagePath, skinPart, inputComponent) { + return { + providerId, + surface: { + id: `${providerId}.hangar`, + styles: { + tokens: { colorAccent: providerId === "alpha" ? "#a00" : "#00a" }, + controlSkins: { + custom: { + children: [{ kind: "control.part", props: { name: skinPart } }], + }, + }, + }, + root: { + id: `${providerId}.root`, + kind: "column", + children: [ + { kind: "image.preview", props: { src: imagePath, label: providerId } }, + { kind: "control.slider", props: { skin: "custom", value: 0.5 } }, + inputComponent, + { kind: "control.button", props: { label: `Launch ${providerId}`, command: `${providerId}.launch` } }, + ], + }, + }, + }; +} + +test("browser lowering isolates two hosts and patches only the bound component", async () => { + const restoreDom = installDom(); + try { + let applyAlphaBinding; + let alphaInputRenders = 0; + const alphaCommands = []; + const betaCommands = []; + const alphaInput = { + id: "alpha.input", + kind: "test.input", + props: { value: "initial" }, + stateBindings: [{ + targetProp: "value", + pointerId: "alpha.input.value", + sourceId: "alpha.state", + schemaId: "alpha.state.v1", + routeKind: "cultmesh", + }], + }; + const alphaSurface = surface("alpha", "/ship.png", "track-alpha", alphaInput); + const betaSurface = surface("beta", "/ship.png", "track-beta", { + id: "beta.input", + kind: "test.input", + props: { value: "beta" }, + }); + const alphaHost = document.querySelector("#a"); + const betaHost = document.querySelector("#b"); + + renderEveSurface(alphaSurface, alphaHost, { + assetUrlResolver: uri => `https://alpha.assets${uri}`, + commandSink: intent => alphaCommands.push(intent), + pluginAdapters: [testInputAdapter(() => { alphaInputRenders++; })], + stateBindingResolver: async () => ({ + latest: async () => "hydrated", + watch: callback => { + applyAlphaBinding = callback; + return () => { applyAlphaBinding = undefined; }; + }, + }), + }); + renderEveSurface(betaSurface, betaHost, { + assetUrlResolver: uri => `https://beta.assets${uri}`, + commandSink: intent => betaCommands.push(intent), + pluginAdapters: [testInputAdapter()], + }); + + await new Promise(resolve => setTimeout(resolve, 0)); + assert.equal(alphaHost.querySelector("img").src, "https://alpha.assets/ship.png"); + assert.equal(betaHost.querySelector("img").src, "https://beta.assets/ship.png"); + assert.ok(alphaHost.querySelector(".track-alpha")); + assert.ok(betaHost.querySelector(".track-beta")); + + alphaHost.querySelector("button").click(); + betaHost.querySelector("button").click(); + assert.equal(alphaCommands[0].providerId, "alpha"); + assert.equal(alphaCommands[0].surfaceId, "alpha.hangar"); + assert.equal(betaCommands[0].providerId, "beta"); + assert.equal(betaCommands[0].surfaceId, "beta.hangar"); + + const alphaRoot = alphaHost.firstElementChild; + const betaRoot = betaHost.firstElementChild; + const focusedInput = alphaHost.querySelector("input[type='text']"); + focusedInput.focus(); + focusedInput.setSelectionRange(1, 4); + alphaHost.scrollTop = 37; + const rendersBeforeUpdates = alphaInputRenders; + for (let index = 0; index < 1000; index++) applyAlphaBinding(`updated-${index}`); + await new Promise(resolve => setTimeout(resolve, 0)); + + const updatedInput = alphaHost.querySelector("input[type='text']"); + assert.equal(alphaHost.firstElementChild, alphaRoot); + assert.equal(betaHost.firstElementChild, betaRoot); + assert.equal(updatedInput.value, "updated-999"); + assert.equal(alphaInputRenders, rendersBeforeUpdates + 1); + assert.equal(document.activeElement, updatedInput); + assert.equal(updatedInput.selectionStart, 1); + assert.equal(updatedInput.selectionEnd, 4); + assert.equal(alphaHost.scrollTop, 37); + } finally { + restoreDom(); + } +});