adaptar-vite-plugin 1.0.7 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/html-tags.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ERROR_BRIDGE_SCRIPT } from "./scripts/error-bridge.js";
2
+ import { NAVIGATION_BRIDGE_SCRIPT } from "./scripts/navigation-bridge.js";
2
3
  import { SELECTION_BRIDGE_SCRIPT } from "./scripts/selection-bridge.js";
3
4
  /**
4
5
  * The inline module that subscribes to the `adaptar:error` custom HMR event
@@ -72,6 +73,12 @@ export function buildHtmlTags() {
72
73
  attrs: { "data-adaptar": "error-bridge" },
73
74
  children: ERROR_BRIDGE_SCRIPT,
74
75
  },
76
+ {
77
+ tag: "script",
78
+ injectTo: "head-prepend",
79
+ attrs: { "data-adaptar": "navigation-bridge" },
80
+ children: NAVIGATION_BRIDGE_SCRIPT,
81
+ },
75
82
  {
76
83
  tag: "script",
77
84
  injectTo: "head-prepend",
package/dist/index.d.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  /**
2
2
  * Adaptar Vite Plugin
3
3
  *
4
- * Provides three layers of error and selection bridging between the
5
- * sandboxed preview iframe and the Adaptar host editor:
4
+ * Provides four preview bridges between the sandboxed iframe and the host:
6
5
  *
7
- * 1. `error-bridge` catches runtime JS errors, resource failures,
8
- * unhandled rejections, and blank-screen scenarios.
9
- * 2. `selection-bridge` powers element inspect / edit mode in the preview.
10
- * 3. `hmr-interceptor` intercepts Vite's internal WebSocket to surface
11
- * build/HMR errors as structured `adaptar:error` events.
6
+ * 1. `error-bridge` - reports blocking runtime and compilation failures.
7
+ * 2. `navigation-bridge` - reports document and History API navigation.
8
+ * 3. `selection-bridge` - powers element inspection and edit mode.
9
+ * 4. `hmr-interceptor` - reports Vite build and HMR failures.
12
10
  */
13
11
  export declare function adaptar(): any;
package/dist/index.js CHANGED
@@ -3,14 +3,12 @@ import { buildHtmlTags } from "./html-tags.js";
3
3
  /**
4
4
  * Adaptar Vite Plugin
5
5
  *
6
- * Provides three layers of error and selection bridging between the
7
- * sandboxed preview iframe and the Adaptar host editor:
6
+ * Provides four preview bridges between the sandboxed iframe and the host:
8
7
  *
9
- * 1. `error-bridge` catches runtime JS errors, resource failures,
10
- * unhandled rejections, and blank-screen scenarios.
11
- * 2. `selection-bridge` powers element inspect / edit mode in the preview.
12
- * 3. `hmr-interceptor` intercepts Vite's internal WebSocket to surface
13
- * build/HMR errors as structured `adaptar:error` events.
8
+ * 1. `error-bridge` - reports blocking runtime and compilation failures.
9
+ * 2. `navigation-bridge` - reports document and History API navigation.
10
+ * 3. `selection-bridge` - powers element inspection and edit mode.
11
+ * 4. `hmr-interceptor` - reports Vite build and HMR failures.
14
12
  */
15
13
  export function adaptar() {
16
14
  return {
@@ -20,7 +18,7 @@ export function adaptar() {
20
18
  return {
21
19
  server: {
22
20
  hmr: {
23
- // Disable Vite's default error overlay — Adaptar renders its own.
21
+ // Adaptar renders its own error surface.
24
22
  overlay: false,
25
23
  },
26
24
  },
@@ -1 +1 @@
1
- export declare const ERROR_BRIDGE_SCRIPT = "(function(){\n if (window.__ADAPTAR_ERROR_BRIDGE__) return;\n window.__ADAPTAR_ERROR_BRIDGE__ = true;\n\n var operationId = new URL(window.location.href).searchParams.get('__adaptarOperation') || undefined;\n var activeBlockingSources = Object.create(null);\n var blankCheckStartedAt = 0;\n var blankCheckTimer;\n var blankFailureReported = false;\n var BLANK_CONFIRMATION_MS = 8000;\n var ROOT_CHECK_INTERVAL_MS = 500;\n\n function readRootHealth() {\n var root = document.getElementById('root');\n var hasRootContent = Boolean(\n root && (root.children.length || (root.textContent || '').trim())\n );\n\n return {\n state: !root ? 'missing' : (hasRootContent ? 'healthy' : 'empty'),\n hasRootContent: hasRootContent,\n readyState: document.readyState\n };\n }\n\n function sendRendered(heartbeat) {\n try {\n var rootHealth = readRootHealth();\n window.parent.postMessage({\n source: 'adaptar-preview',\n type: heartbeat ? 'render-heartbeat' : 'preview-rendered',\n operationId: operationId,\n renderedAt: Date.now(),\n hasRootContent: rootHealth.hasRootContent,\n rootHealth: rootHealth\n }, '*');\n } catch (_) {}\n }\n\n function classifyResource(target, url) {\n var tag = String((target && target.tagName) || '').toLowerCase();\n var rel = String((target && target.rel) || '').toLowerCase();\n var as = String((target && target.as) || '').toLowerCase();\n var type = String((target && target.type) || '').toLowerCase();\n var value = String(url || '').split(/[?#]/)[0].toLowerCase();\n\n if (\n as === 'font' ||\n type.indexOf('font/') === 0 ||\n /\\.(woff2?|ttf|otf|eot)$/.test(value)\n ) return 'font';\n if (\n tag === 'img' ||\n as === 'image' ||\n /\\.(avif|bmp|gif|ico|jpe?g|png|svg|webp)$/.test(value)\n ) return 'image';\n if (\n tag === 'audio' || tag === 'video' || tag === 'source' || tag === 'track' ||\n as === 'audio' || as === 'video' ||\n /\\.(aac|flac|m4a|m4v|mov|mp3|mp4|ogg|ogv|wav|webm|vtt)$/.test(value)\n ) return 'media';\n if (\n tag === 'script' ||\n as === 'script' ||\n rel === 'modulepreload' ||\n type === 'module' ||\n /\\.(c|m)?(j|t)sx?$/.test(value)\n ) return 'module';\n if (\n (tag === 'link' && rel === 'stylesheet') ||\n as === 'style' ||\n type === 'text/css' ||\n /\\.(css|less|sass|scss|styl)$/.test(value)\n ) return 'stylesheet';\n return 'unknown';\n }\n\n function resourceFailureMessage(resourceKind, url) {\n var labels = {\n font: 'Failed to load font: ',\n image: 'Failed to load image: ',\n media: 'Failed to load media: ',\n module: 'Failed to load module: ',\n stylesheet: 'Failed to load stylesheet: ',\n unknown: 'Failed to load resource: '\n };\n return (labels[resourceKind] || labels.unknown) + url;\n }\n\n function send(message, stack, filename, lineno, colno, source, plugin, metadata) {\n try {\n var details = metadata || {};\n var rootHealth = readRootHealth();\n var renderBlocking = details.renderBlocking === true || (\n details.blockWhenRootEmpty === true && !rootHealth.hasRootContent\n );\n var errorSource = source || 'unknown';\n\n if (renderBlocking) {\n activeBlockingSources[errorSource] = true;\n }\n\n window.parent.postMessage({\n source: 'adaptar-preview',\n type: 'error',\n operationId: operationId,\n error: {\n message: message || 'Unknown error',\n stack: stack || '',\n filename: filename,\n lineno: lineno,\n colno: colno,\n source: source,\n plugin: plugin,\n classification: renderBlocking ? 'render-blocking' : 'diagnostic',\n category: details.category || 'runtime',\n severity: renderBlocking ? 'error' : 'advisory',\n resourceKind: details.resourceKind || 'unknown',\n renderBlocking: renderBlocking,\n rootHealth: rootHealth\n }\n }, '*');\n } catch (_) {}\n }\n\n function recoverBlockingErrorsIfRendered(reason) {\n try {\n var rootHealth = readRootHealth();\n var resolvedSources = Object.keys(activeBlockingSources);\n if (!rootHealth.hasRootContent || resolvedSources.length === 0) return;\n\n activeBlockingSources = Object.create(null);\n window.parent.postMessage({\n source: 'adaptar-preview',\n type: 'preview-recovered',\n operationId: operationId,\n recoveredAt: Date.now(),\n reason: reason || 'root-healthy',\n resolvedSources: resolvedSources,\n hasRootContent: true,\n rootHealth: rootHealth\n }, '*');\n } catch (_) {}\n }\n\n // Resource failures remain observable, but only a module required before\n // the application mounts can be classified as render-blocking. Images,\n // fonts, media, and stylesheets are quality diagnostics.\n window.addEventListener('error', function(e) {\n var target = e.target || e.srcElement;\n if (target && target !== window && target.tagName) {\n var url = target.src || target.href || target.currentSrc;\n var resourceKind = classifyResource(target, url);\n if (url || resourceKind !== 'unknown') {\n send(\n resourceFailureMessage(resourceKind, url || '(unknown resource URL)'),\n '', url, undefined, undefined, 'resource-load', undefined,\n {\n category: 'resource',\n resourceKind: resourceKind,\n blockWhenRootEmpty: resourceKind === 'module'\n }\n );\n return;\n }\n }\n\n send(\n e.message || (e.error && e.error.message) || 'Runtime error occurred',\n e.error ? e.error.stack : '',\n e.filename, e.lineno, e.colno, 'runtime', undefined,\n { category: 'runtime', resourceKind: 'module', blockWhenRootEmpty: true }\n );\n }, true);\n\n // An async failure is blocking only when the application has no rendered\n // root. Interaction failures in an otherwise-rendered page remain visible\n // diagnostics and never invalidate the candidate by themselves.\n window.addEventListener('unhandledrejection', function(e) {\n var r = e.reason;\n send(\n r && r.message ? r.message : String(r || 'Unhandled Promise Rejection'),\n r && r.stack,\n r && r.fileName,\n r && r.lineNumber,\n r && r.columnNumber,\n 'unhandledrejection', undefined,\n { category: 'runtime', resourceKind: 'module', blockWhenRootEmpty: true }\n );\n });\n\n // Intercept only errors that indicate a Vite/module compilation failure.\n // Their render-blocking status is still tied to an empty application root;\n // Vite HMR compile failures are classified separately by the server bridge.\n var nativeConsoleError = console.error;\n console.error = function() {\n var args = Array.prototype.slice.call(arguments);\n var msg = args.map(function(a) {\n return typeof a === 'string' ? a : (a && a.message ? a.message : String(a));\n }).join(' ');\n if (/Failed to load module|Internal Server Error|Syntax Error/i.test(msg)) {\n send(\n msg, '', undefined, undefined, undefined, 'console.error', undefined,\n { category: 'compile', resourceKind: 'module', blockWhenRootEmpty: true }\n );\n }\n return nativeConsoleError.apply(console, args);\n };\n\n function sendStable(rootHealth) {\n var images = Array.prototype.slice.call(document.querySelectorAll('img'));\n var controls = Array.prototype.slice.call(\n document.querySelectorAll('button, a[href], input, select, textarea')\n );\n var checks = {\n viewportWidth: document.documentElement.clientWidth || window.innerWidth || 0,\n horizontalOverflow:\n document.documentElement.scrollWidth >\n (document.documentElement.clientWidth || window.innerWidth || 0) + 2,\n brokenImages: images.filter(function(img) {\n return img.complete && img.naturalWidth === 0;\n }).length,\n missingImageAlt: images.filter(function(img) {\n return !img.hasAttribute('alt');\n }).length,\n unlabeledControls: controls.filter(function(control) {\n var label = (\n control.getAttribute('aria-label') ||\n control.getAttribute('title') ||\n control.textContent ||\n control.value ||\n ''\n ).trim();\n return !label;\n }).length\n };\n var diagnostics = [];\n\n if (checks.horizontalOverflow) {\n diagnostics.push({\n code: 'horizontal-overflow',\n classification: 'diagnostic',\n category: 'layout',\n severity: 'advisory',\n resourceKind: 'quality',\n renderBlocking: false,\n message: 'The preview has horizontal overflow.'\n });\n }\n if (checks.brokenImages > 0) {\n diagnostics.push({\n code: 'broken-images',\n classification: 'diagnostic',\n category: 'asset',\n severity: 'advisory',\n resourceKind: 'image',\n renderBlocking: false,\n message: 'One or more images could not be displayed.',\n count: checks.brokenImages\n });\n }\n if (checks.missingImageAlt > 0) {\n diagnostics.push({\n code: 'missing-image-alt',\n classification: 'diagnostic',\n category: 'accessibility',\n severity: 'advisory',\n resourceKind: 'image',\n renderBlocking: false,\n message: 'One or more images are missing alternative text.',\n count: checks.missingImageAlt\n });\n }\n if (checks.unlabeledControls > 0) {\n diagnostics.push({\n code: 'unlabeled-controls',\n classification: 'diagnostic',\n category: 'accessibility',\n severity: 'advisory',\n resourceKind: 'control',\n renderBlocking: false,\n message: 'One or more controls do not have an accessible label.',\n count: checks.unlabeledControls\n });\n }\n\n window.parent.postMessage({\n source: 'adaptar-preview',\n type: 'preview-stable',\n operationId: operationId,\n stableAt: Date.now(),\n hasRootContent: rootHealth.hasRootContent,\n rootHealth: rootHealth,\n checks: checks,\n diagnostics: diagnostics\n }, '*');\n }\n\n function confirmPreviewHealth() {\n var rootHealth = readRootHealth();\n if (rootHealth.hasRootContent) {\n if (blankCheckTimer) {\n clearTimeout(blankCheckTimer);\n blankCheckTimer = undefined;\n }\n blankCheckStartedAt = 0;\n blankFailureReported = false;\n recoverBlockingErrorsIfRendered('root-recovered');\n sendStable(rootHealth);\n return;\n }\n\n if (!blankCheckStartedAt) blankCheckStartedAt = Date.now();\n if (Date.now() - blankCheckStartedAt >= BLANK_CONFIRMATION_MS) {\n if (!blankFailureReported) {\n blankFailureReported = true;\n send(\n 'Runtime error: Preview failed to render; a component or import may have failed silently.',\n '', location.href, undefined, undefined, 'blank-screen', undefined,\n { category: 'render', resourceKind: 'document', renderBlocking: true }\n );\n }\n blankCheckTimer = setTimeout(confirmPreviewHealth, ROOT_CHECK_INTERVAL_MS);\n return;\n }\n\n blankCheckTimer = setTimeout(confirmPreviewHealth, ROOT_CHECK_INTERVAL_MS);\n }\n\n // A blank root is blocking only when it remains blank across a sustained\n // observation window. Normal React mounting and Vite reloads are transient.\n window.addEventListener('load', function() {\n blankCheckStartedAt = Date.now();\n requestAnimationFrame(function() {\n requestAnimationFrame(function() {\n sendRendered(false);\n });\n });\n\n setTimeout(confirmPreviewHealth, 3000);\n });\n\n window.setInterval(function() {\n sendRendered(true);\n }, 20000);\n window.setInterval(function() {\n recoverBlockingErrorsIfRendered('root-health-monitor');\n }, 1000);\n})();";
1
+ export declare const ERROR_BRIDGE_SCRIPT = "(function(){\n if (window.__ADAPTAR_ERROR_BRIDGE__) return;\n window.__ADAPTAR_ERROR_BRIDGE__ = true;\n\n var operationId = new URL(window.location.href).searchParams.get('__adaptarOperation') || undefined;\n var activeBlockingSources = Object.create(null);\n var blankCheckStartedAt = 0;\n var blankCheckTimer;\n var blankFailureReported = false;\n var BLANK_CONFIRMATION_MS = 8000;\n var ROOT_CHECK_INTERVAL_MS = 500;\n\n function readRootHealth() {\n var root = document.getElementById('root');\n var hasRootContent = Boolean(\n root && (root.children.length || (root.textContent || '').trim())\n );\n\n return {\n state: !root ? 'missing' : (hasRootContent ? 'healthy' : 'empty'),\n hasRootContent: hasRootContent,\n readyState: document.readyState\n };\n }\n\n function sendRendered(heartbeat) {\n try {\n var rootHealth = readRootHealth();\n window.parent.postMessage({\n source: 'adaptar-preview',\n type: heartbeat ? 'render-heartbeat' : 'preview-rendered',\n operationId: operationId,\n renderedAt: Date.now(),\n hasRootContent: rootHealth.hasRootContent,\n rootHealth: rootHealth\n }, '*');\n } catch (_) {}\n }\n\n function classifyResource(target, url) {\n var tag = String((target && target.tagName) || '').toLowerCase();\n var rel = String((target && target.rel) || '').toLowerCase();\n var as = String((target && target.as) || '').toLowerCase();\n var type = String((target && target.type) || '').toLowerCase();\n var value = String(url || '').split(/[?#]/)[0].toLowerCase();\n\n if (\n as === 'font' ||\n type.indexOf('font/') === 0 ||\n /\\.(woff2?|ttf|otf|eot)$/.test(value)\n ) return 'font';\n if (\n tag === 'img' ||\n as === 'image' ||\n /\\.(avif|bmp|gif|ico|jpe?g|png|svg|webp)$/.test(value)\n ) return 'image';\n if (\n tag === 'audio' || tag === 'video' || tag === 'source' || tag === 'track' ||\n as === 'audio' || as === 'video' ||\n /\\.(aac|flac|m4a|m4v|mov|mp3|mp4|ogg|ogv|wav|webm|vtt)$/.test(value)\n ) return 'media';\n if (\n tag === 'script' ||\n as === 'script' ||\n rel === 'modulepreload' ||\n type === 'module' ||\n /\\.(c|m)?(j|t)sx?$/.test(value)\n ) return 'module';\n if (\n (tag === 'link' && rel === 'stylesheet') ||\n as === 'style' ||\n type === 'text/css' ||\n /\\.(css|less|sass|scss|styl)$/.test(value)\n ) return 'stylesheet';\n return 'unknown';\n }\n\n function resourceFailureMessage(resourceKind, url) {\n var labels = {\n font: 'Failed to load font: ',\n image: 'Failed to load image: ',\n media: 'Failed to load media: ',\n module: 'Failed to load module: ',\n stylesheet: 'Failed to load stylesheet: ',\n unknown: 'Failed to load resource: '\n };\n return (labels[resourceKind] || labels.unknown) + url;\n }\n\n function send(message, stack, filename, lineno, colno, source, plugin, metadata) {\n try {\n var details = metadata || {};\n var rootHealth = readRootHealth();\n var renderBlocking = details.renderBlocking === true || (\n details.blockWhenRootEmpty === true && !rootHealth.hasRootContent\n );\n var errorSource = source || 'unknown';\n\n if (renderBlocking) {\n activeBlockingSources[errorSource] = true;\n }\n\n window.parent.postMessage({\n source: 'adaptar-preview',\n type: 'error',\n operationId: operationId,\n error: {\n message: message || 'Unknown error',\n stack: stack || '',\n filename: filename,\n lineno: lineno,\n colno: colno,\n source: source,\n plugin: plugin,\n classification: renderBlocking ? 'render-blocking' : 'diagnostic',\n category: details.category || 'runtime',\n severity: renderBlocking ? 'error' : 'advisory',\n resourceKind: details.resourceKind || 'unknown',\n renderBlocking: renderBlocking,\n rootHealth: rootHealth\n }\n }, '*');\n } catch (_) {}\n }\n\n function recoverBlockingErrorsIfRendered(reason) {\n try {\n var rootHealth = readRootHealth();\n var resolvedSources = Object.keys(activeBlockingSources);\n if (!rootHealth.hasRootContent || resolvedSources.length === 0) return;\n\n activeBlockingSources = Object.create(null);\n window.parent.postMessage({\n source: 'adaptar-preview',\n type: 'preview-recovered',\n operationId: operationId,\n recoveredAt: Date.now(),\n reason: reason || 'root-healthy',\n resolvedSources: resolvedSources,\n hasRootContent: true,\n rootHealth: rootHealth\n }, '*');\n } catch (_) {}\n }\n\n // Resource failures remain observable, but only a module required before\n // the application mounts can be classified as render-blocking. Images,\n // fonts, media, and stylesheets are quality diagnostics.\n window.addEventListener('error', function(e) {\n var target = e.target || e.srcElement;\n if (target && target !== window && target.tagName) {\n var url = target.src || target.href || target.currentSrc;\n var resourceKind = classifyResource(target, url);\n // Inline/module wrapper elements have no actionable resource URL. Vite's\n // HMR bridge and the sustained blank-root monitor report real compile or\n // render failures with useful evidence, so do not manufacture an\n // \"unknown resource URL\" module error here.\n if (!url) return;\n if (url || resourceKind !== 'unknown') {\n send(\n resourceFailureMessage(resourceKind, url),\n '', url, undefined, undefined, 'resource-load', undefined,\n {\n category: 'resource',\n resourceKind: resourceKind,\n blockWhenRootEmpty: resourceKind === 'module'\n }\n );\n return;\n }\n }\n\n send(\n e.message || (e.error && e.error.message) || 'Runtime error occurred',\n e.error ? e.error.stack : '',\n e.filename, e.lineno, e.colno, 'runtime', undefined,\n { category: 'runtime', resourceKind: 'module', blockWhenRootEmpty: true }\n );\n }, true);\n\n // An async failure is blocking only when the application has no rendered\n // root. Interaction failures in an otherwise-rendered page remain visible\n // diagnostics and never invalidate the candidate by themselves.\n window.addEventListener('unhandledrejection', function(e) {\n var r = e.reason;\n send(\n r && r.message ? r.message : String(r || 'Unhandled Promise Rejection'),\n r && r.stack,\n r && r.fileName,\n r && r.lineNumber,\n r && r.columnNumber,\n 'unhandledrejection', undefined,\n { category: 'runtime', resourceKind: 'module', blockWhenRootEmpty: true }\n );\n });\n\n // Intercept only errors that indicate a Vite/module compilation failure.\n // Their render-blocking status is still tied to an empty application root;\n // Vite HMR compile failures are classified separately by the server bridge.\n var nativeConsoleError = console.error;\n console.error = function() {\n var args = Array.prototype.slice.call(arguments);\n var msg = args.map(function(a) {\n return typeof a === 'string' ? a : (a && a.message ? a.message : String(a));\n }).join(' ');\n if (/Failed to load module|Internal Server Error|Syntax Error/i.test(msg)) {\n send(\n msg, '', undefined, undefined, undefined, 'console.error', undefined,\n { category: 'compile', resourceKind: 'module', blockWhenRootEmpty: true }\n );\n }\n return nativeConsoleError.apply(console, args);\n };\n\n function sendStable(rootHealth) {\n var images = Array.prototype.slice.call(document.querySelectorAll('img'));\n var controls = Array.prototype.slice.call(\n document.querySelectorAll('button, a[href], input, select, textarea')\n );\n var checks = {\n viewportWidth: document.documentElement.clientWidth || window.innerWidth || 0,\n horizontalOverflow:\n document.documentElement.scrollWidth >\n (document.documentElement.clientWidth || window.innerWidth || 0) + 2,\n brokenImages: images.filter(function(img) {\n return img.complete && img.naturalWidth === 0;\n }).length,\n missingImageAlt: images.filter(function(img) {\n return !img.hasAttribute('alt');\n }).length,\n unlabeledControls: controls.filter(function(control) {\n var label = (\n control.getAttribute('aria-label') ||\n control.getAttribute('title') ||\n control.textContent ||\n control.value ||\n ''\n ).trim();\n return !label;\n }).length\n };\n var diagnostics = [];\n\n if (checks.horizontalOverflow) {\n diagnostics.push({\n code: 'horizontal-overflow',\n classification: 'diagnostic',\n category: 'layout',\n severity: 'advisory',\n resourceKind: 'quality',\n renderBlocking: false,\n message: 'The preview has horizontal overflow.'\n });\n }\n if (checks.brokenImages > 0) {\n diagnostics.push({\n code: 'broken-images',\n classification: 'diagnostic',\n category: 'asset',\n severity: 'advisory',\n resourceKind: 'image',\n renderBlocking: false,\n message: 'One or more images could not be displayed.',\n count: checks.brokenImages\n });\n }\n if (checks.missingImageAlt > 0) {\n diagnostics.push({\n code: 'missing-image-alt',\n classification: 'diagnostic',\n category: 'accessibility',\n severity: 'advisory',\n resourceKind: 'image',\n renderBlocking: false,\n message: 'One or more images are missing alternative text.',\n count: checks.missingImageAlt\n });\n }\n if (checks.unlabeledControls > 0) {\n diagnostics.push({\n code: 'unlabeled-controls',\n classification: 'diagnostic',\n category: 'accessibility',\n severity: 'advisory',\n resourceKind: 'control',\n renderBlocking: false,\n message: 'One or more controls do not have an accessible label.',\n count: checks.unlabeledControls\n });\n }\n\n window.parent.postMessage({\n source: 'adaptar-preview',\n type: 'preview-stable',\n operationId: operationId,\n stableAt: Date.now(),\n hasRootContent: rootHealth.hasRootContent,\n rootHealth: rootHealth,\n checks: checks,\n diagnostics: diagnostics\n }, '*');\n }\n\n function confirmPreviewHealth() {\n var rootHealth = readRootHealth();\n if (rootHealth.hasRootContent) {\n if (blankCheckTimer) {\n clearTimeout(blankCheckTimer);\n blankCheckTimer = undefined;\n }\n blankCheckStartedAt = 0;\n blankFailureReported = false;\n recoverBlockingErrorsIfRendered('root-recovered');\n sendStable(rootHealth);\n return;\n }\n\n if (!blankCheckStartedAt) blankCheckStartedAt = Date.now();\n if (Date.now() - blankCheckStartedAt >= BLANK_CONFIRMATION_MS) {\n if (!blankFailureReported) {\n blankFailureReported = true;\n send(\n 'Runtime error: Preview failed to render; a component or import may have failed silently.',\n '', location.href, undefined, undefined, 'blank-screen', undefined,\n { category: 'render', resourceKind: 'document', renderBlocking: true }\n );\n }\n blankCheckTimer = setTimeout(confirmPreviewHealth, ROOT_CHECK_INTERVAL_MS);\n return;\n }\n\n blankCheckTimer = setTimeout(confirmPreviewHealth, ROOT_CHECK_INTERVAL_MS);\n }\n\n // A blank root is blocking only when it remains blank across a sustained\n // observation window. Normal React mounting and Vite reloads are transient.\n window.addEventListener('load', function() {\n blankCheckStartedAt = Date.now();\n requestAnimationFrame(function() {\n requestAnimationFrame(function() {\n sendRendered(false);\n });\n });\n\n setTimeout(confirmPreviewHealth, 3000);\n });\n\n window.setInterval(function() {\n sendRendered(true);\n }, 20000);\n window.setInterval(function() {\n recoverBlockingErrorsIfRendered('root-health-monitor');\n }, 1000);\n})();";
@@ -151,9 +151,14 @@ export const ERROR_BRIDGE_SCRIPT = /* js */ `(function(){
151
151
  if (target && target !== window && target.tagName) {
152
152
  var url = target.src || target.href || target.currentSrc;
153
153
  var resourceKind = classifyResource(target, url);
154
+ // Inline/module wrapper elements have no actionable resource URL. Vite's
155
+ // HMR bridge and the sustained blank-root monitor report real compile or
156
+ // render failures with useful evidence, so do not manufacture an
157
+ // "unknown resource URL" module error here.
158
+ if (!url) return;
154
159
  if (url || resourceKind !== 'unknown') {
155
160
  send(
156
- resourceFailureMessage(resourceKind, url || '(unknown resource URL)'),
161
+ resourceFailureMessage(resourceKind, url),
157
162
  '', url, undefined, undefined, 'resource-load', undefined,
158
163
  {
159
164
  category: 'resource',
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Reports the preview's browser location to the Adaptar host.
3
+ *
4
+ * The preview runs on a different origin, so the host cannot read
5
+ * `iframe.contentWindow.location`. Intercepting the History API also covers
6
+ * client-side routers, whose navigation does not trigger an iframe load.
7
+ */
8
+ export declare const NAVIGATION_BRIDGE_SCRIPT = "(function() {\n if (window.__ADAPTAR_NAVIGATION_BRIDGE__) return;\n window.__ADAPTAR_NAVIGATION_BRIDGE__ = true;\n\n var INTERNAL_OPERATION_PARAM = '__adaptarOperation';\n var operationId = new URL(window.location.href).searchParams.get(INTERNAL_OPERATION_PARAM) || undefined;\n var lastLocation = '';\n\n function readLocation() {\n var url = new URL(window.location.href);\n url.searchParams.delete(INTERNAL_OPERATION_PARAM);\n\n return {\n pathname: url.pathname || '/',\n search: url.search,\n hash: url.hash\n };\n }\n\n function reportLocation(reason) {\n try {\n var location = readLocation();\n var signature = location.pathname + location.search + location.hash;\n if (signature === lastLocation) return;\n lastLocation = signature;\n\n window.parent.postMessage({\n source: 'adaptar-preview',\n type: 'preview-navigation',\n operationId: operationId,\n reason: reason,\n location: location\n }, '*');\n } catch (_) {}\n }\n\n function scheduleReport(reason) {\n if (typeof queueMicrotask === 'function') {\n queueMicrotask(function() {\n reportLocation(reason);\n });\n return;\n }\n\n Promise.resolve().then(function() {\n reportLocation(reason);\n });\n }\n\n function instrumentHistory(method) {\n try {\n var original = window.history[method];\n if (typeof original !== 'function') return;\n\n window.history[method] = function() {\n var result = original.apply(this, arguments);\n scheduleReport(method);\n return result;\n };\n } catch (_) {}\n }\n\n // Older generations occasionally interpreted \"return to the top\" as a\n // route and emitted href=\"/top\". Treat that legacy value as a scroll\n // action so it cannot replace the working preview document with a route\n // that does not exist. New generations should use href=\"#top\" instead.\n function normalizeLegacyTopLink(event) {\n if (!event || event.defaultPrevented) return;\n if (event.button != null && event.button !== 0) return;\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;\n\n var target = event.target;\n var anchor = target && typeof target.closest === 'function'\n ? target.closest('a[href]')\n : null;\n if (!anchor || typeof anchor.getAttribute !== 'function') return;\n\n var href = anchor.getAttribute('href');\n if (href !== '/top' && href !== '/top/') return;\n\n event.preventDefault();\n try {\n window.history.replaceState({}, '', '/');\n } catch (_) {}\n\n try {\n var root = window.document && window.document.getElementById\n ? window.document.getElementById('top')\n : null;\n if (root && typeof root.scrollIntoView === 'function') {\n root.scrollIntoView({ behavior: 'smooth', block: 'start' });\n } else if (typeof window.scrollTo === 'function') {\n window.scrollTo({ top: 0, behavior: 'smooth' });\n }\n } catch (_) {}\n\n scheduleReport('legacy-top-link');\n }\n\n instrumentHistory('pushState');\n instrumentHistory('replaceState');\n\n window.addEventListener('popstate', function() {\n scheduleReport('popstate');\n });\n window.addEventListener('hashchange', function() {\n scheduleReport('hashchange');\n });\n window.addEventListener('pageshow', function() {\n scheduleReport('pageshow');\n });\n\n if (window.document && typeof window.document.addEventListener === 'function') {\n window.document.addEventListener('click', normalizeLegacyTopLink);\n }\n\n reportLocation('initial');\n})();";
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Reports the preview's browser location to the Adaptar host.
3
+ *
4
+ * The preview runs on a different origin, so the host cannot read
5
+ * `iframe.contentWindow.location`. Intercepting the History API also covers
6
+ * client-side routers, whose navigation does not trigger an iframe load.
7
+ */
8
+ export const NAVIGATION_BRIDGE_SCRIPT = /* js */ `(function() {
9
+ if (window.__ADAPTAR_NAVIGATION_BRIDGE__) return;
10
+ window.__ADAPTAR_NAVIGATION_BRIDGE__ = true;
11
+
12
+ var INTERNAL_OPERATION_PARAM = '__adaptarOperation';
13
+ var operationId = new URL(window.location.href).searchParams.get(INTERNAL_OPERATION_PARAM) || undefined;
14
+ var lastLocation = '';
15
+
16
+ function readLocation() {
17
+ var url = new URL(window.location.href);
18
+ url.searchParams.delete(INTERNAL_OPERATION_PARAM);
19
+
20
+ return {
21
+ pathname: url.pathname || '/',
22
+ search: url.search,
23
+ hash: url.hash
24
+ };
25
+ }
26
+
27
+ function reportLocation(reason) {
28
+ try {
29
+ var location = readLocation();
30
+ var signature = location.pathname + location.search + location.hash;
31
+ if (signature === lastLocation) return;
32
+ lastLocation = signature;
33
+
34
+ window.parent.postMessage({
35
+ source: 'adaptar-preview',
36
+ type: 'preview-navigation',
37
+ operationId: operationId,
38
+ reason: reason,
39
+ location: location
40
+ }, '*');
41
+ } catch (_) {}
42
+ }
43
+
44
+ function scheduleReport(reason) {
45
+ if (typeof queueMicrotask === 'function') {
46
+ queueMicrotask(function() {
47
+ reportLocation(reason);
48
+ });
49
+ return;
50
+ }
51
+
52
+ Promise.resolve().then(function() {
53
+ reportLocation(reason);
54
+ });
55
+ }
56
+
57
+ function instrumentHistory(method) {
58
+ try {
59
+ var original = window.history[method];
60
+ if (typeof original !== 'function') return;
61
+
62
+ window.history[method] = function() {
63
+ var result = original.apply(this, arguments);
64
+ scheduleReport(method);
65
+ return result;
66
+ };
67
+ } catch (_) {}
68
+ }
69
+
70
+ // Older generations occasionally interpreted "return to the top" as a
71
+ // route and emitted href="/top". Treat that legacy value as a scroll
72
+ // action so it cannot replace the working preview document with a route
73
+ // that does not exist. New generations should use href="#top" instead.
74
+ function normalizeLegacyTopLink(event) {
75
+ if (!event || event.defaultPrevented) return;
76
+ if (event.button != null && event.button !== 0) return;
77
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
78
+
79
+ var target = event.target;
80
+ var anchor = target && typeof target.closest === 'function'
81
+ ? target.closest('a[href]')
82
+ : null;
83
+ if (!anchor || typeof anchor.getAttribute !== 'function') return;
84
+
85
+ var href = anchor.getAttribute('href');
86
+ if (href !== '/top' && href !== '/top/') return;
87
+
88
+ event.preventDefault();
89
+ try {
90
+ window.history.replaceState({}, '', '/');
91
+ } catch (_) {}
92
+
93
+ try {
94
+ var root = window.document && window.document.getElementById
95
+ ? window.document.getElementById('top')
96
+ : null;
97
+ if (root && typeof root.scrollIntoView === 'function') {
98
+ root.scrollIntoView({ behavior: 'smooth', block: 'start' });
99
+ } else if (typeof window.scrollTo === 'function') {
100
+ window.scrollTo({ top: 0, behavior: 'smooth' });
101
+ }
102
+ } catch (_) {}
103
+
104
+ scheduleReport('legacy-top-link');
105
+ }
106
+
107
+ instrumentHistory('pushState');
108
+ instrumentHistory('replaceState');
109
+
110
+ window.addEventListener('popstate', function() {
111
+ scheduleReport('popstate');
112
+ });
113
+ window.addEventListener('hashchange', function() {
114
+ scheduleReport('hashchange');
115
+ });
116
+ window.addEventListener('pageshow', function() {
117
+ scheduleReport('pageshow');
118
+ });
119
+
120
+ if (window.document && typeof window.document.addEventListener === 'function') {
121
+ window.document.addEventListener('click', normalizeLegacyTopLink);
122
+ }
123
+
124
+ reportLocation('initial');
125
+ })();`;
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "adaptar-vite-plugin",
3
- "version": "1.0.7",
4
- "description": "Vite plugin for Adaptar preview error and selection bridging",
3
+ "version": "1.0.10",
4
+ "description": "Vite plugin for Adaptar preview error, navigation, and selection bridging",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "type": "module",
8
8
  "scripts": {
9
9
  "build": "tsc",
10
- "dev": "tsc -w"
10
+ "dev": "tsc -w",
11
+ "test": "npm run build && node --test test/*.test.mjs"
11
12
  },
12
13
  "devDependencies": {
13
14
  "@types/node": "^20.0.0",
package/src/html-tags.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { HtmlTagDescriptor } from "vite";
2
- import { ERROR_BRIDGE_SCRIPT } from "./scripts/error-bridge.js";
3
- import { SELECTION_BRIDGE_SCRIPT } from "./scripts/selection-bridge.js";
1
+ import type { HtmlTagDescriptor } from "vite";
2
+ import { ERROR_BRIDGE_SCRIPT } from "./scripts/error-bridge.js";
3
+ import { NAVIGATION_BRIDGE_SCRIPT } from "./scripts/navigation-bridge.js";
4
+ import { SELECTION_BRIDGE_SCRIPT } from "./scripts/selection-bridge.js";
4
5
 
5
6
  /**
6
7
  * The inline module that subscribes to the `adaptar:error` custom HMR event
@@ -75,11 +76,17 @@ export function buildHtmlTags(): HtmlTagDescriptor[] {
75
76
  attrs: { "data-adaptar": "error-bridge" },
76
77
  children: ERROR_BRIDGE_SCRIPT,
77
78
  },
78
- {
79
- tag: "script",
80
- injectTo: "head-prepend",
81
- attrs: { "data-adaptar": "selection-bridge" },
82
- children: SELECTION_BRIDGE_SCRIPT,
79
+ {
80
+ tag: "script",
81
+ injectTo: "head-prepend",
82
+ attrs: { "data-adaptar": "navigation-bridge" },
83
+ children: NAVIGATION_BRIDGE_SCRIPT,
84
+ },
85
+ {
86
+ tag: "script",
87
+ injectTo: "head-prepend",
88
+ attrs: { "data-adaptar": "selection-bridge" },
89
+ children: SELECTION_BRIDGE_SCRIPT,
83
90
  },
84
91
  {
85
92
  tag: "script",
package/src/index.ts CHANGED
@@ -4,14 +4,12 @@ import { buildHtmlTags } from "./html-tags.js";
4
4
  /**
5
5
  * Adaptar Vite Plugin
6
6
  *
7
- * Provides three layers of error and selection bridging between the
8
- * sandboxed preview iframe and the Adaptar host editor:
7
+ * Provides four preview bridges between the sandboxed iframe and the host:
9
8
  *
10
- * 1. `error-bridge` catches runtime JS errors, resource failures,
11
- * unhandled rejections, and blank-screen scenarios.
12
- * 2. `selection-bridge` powers element inspect / edit mode in the preview.
13
- * 3. `hmr-interceptor` intercepts Vite's internal WebSocket to surface
14
- * build/HMR errors as structured `adaptar:error` events.
9
+ * 1. `error-bridge` - reports blocking runtime and compilation failures.
10
+ * 2. `navigation-bridge` - reports document and History API navigation.
11
+ * 3. `selection-bridge` - powers element inspection and edit mode.
12
+ * 4. `hmr-interceptor` - reports Vite build and HMR failures.
15
13
  */
16
14
  export function adaptar(): any {
17
15
  return {
@@ -22,7 +20,7 @@ export function adaptar(): any {
22
20
  return {
23
21
  server: {
24
22
  hmr: {
25
- // Disable Vite's default error overlay — Adaptar renders its own.
23
+ // Adaptar renders its own error surface.
26
24
  overlay: false,
27
25
  },
28
26
  },
@@ -151,9 +151,14 @@ export const ERROR_BRIDGE_SCRIPT = /* js */ `(function(){
151
151
  if (target && target !== window && target.tagName) {
152
152
  var url = target.src || target.href || target.currentSrc;
153
153
  var resourceKind = classifyResource(target, url);
154
+ // Inline/module wrapper elements have no actionable resource URL. Vite's
155
+ // HMR bridge and the sustained blank-root monitor report real compile or
156
+ // render failures with useful evidence, so do not manufacture an
157
+ // "unknown resource URL" module error here.
158
+ if (!url) return;
154
159
  if (url || resourceKind !== 'unknown') {
155
160
  send(
156
- resourceFailureMessage(resourceKind, url || '(unknown resource URL)'),
161
+ resourceFailureMessage(resourceKind, url),
157
162
  '', url, undefined, undefined, 'resource-load', undefined,
158
163
  {
159
164
  category: 'resource',
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Reports the preview's browser location to the Adaptar host.
3
+ *
4
+ * The preview runs on a different origin, so the host cannot read
5
+ * `iframe.contentWindow.location`. Intercepting the History API also covers
6
+ * client-side routers, whose navigation does not trigger an iframe load.
7
+ */
8
+ export const NAVIGATION_BRIDGE_SCRIPT = /* js */ `(function() {
9
+ if (window.__ADAPTAR_NAVIGATION_BRIDGE__) return;
10
+ window.__ADAPTAR_NAVIGATION_BRIDGE__ = true;
11
+
12
+ var INTERNAL_OPERATION_PARAM = '__adaptarOperation';
13
+ var operationId = new URL(window.location.href).searchParams.get(INTERNAL_OPERATION_PARAM) || undefined;
14
+ var lastLocation = '';
15
+
16
+ function readLocation() {
17
+ var url = new URL(window.location.href);
18
+ url.searchParams.delete(INTERNAL_OPERATION_PARAM);
19
+
20
+ return {
21
+ pathname: url.pathname || '/',
22
+ search: url.search,
23
+ hash: url.hash
24
+ };
25
+ }
26
+
27
+ function reportLocation(reason) {
28
+ try {
29
+ var location = readLocation();
30
+ var signature = location.pathname + location.search + location.hash;
31
+ if (signature === lastLocation) return;
32
+ lastLocation = signature;
33
+
34
+ window.parent.postMessage({
35
+ source: 'adaptar-preview',
36
+ type: 'preview-navigation',
37
+ operationId: operationId,
38
+ reason: reason,
39
+ location: location
40
+ }, '*');
41
+ } catch (_) {}
42
+ }
43
+
44
+ function scheduleReport(reason) {
45
+ if (typeof queueMicrotask === 'function') {
46
+ queueMicrotask(function() {
47
+ reportLocation(reason);
48
+ });
49
+ return;
50
+ }
51
+
52
+ Promise.resolve().then(function() {
53
+ reportLocation(reason);
54
+ });
55
+ }
56
+
57
+ function instrumentHistory(method) {
58
+ try {
59
+ var original = window.history[method];
60
+ if (typeof original !== 'function') return;
61
+
62
+ window.history[method] = function() {
63
+ var result = original.apply(this, arguments);
64
+ scheduleReport(method);
65
+ return result;
66
+ };
67
+ } catch (_) {}
68
+ }
69
+
70
+ // Older generations occasionally interpreted "return to the top" as a
71
+ // route and emitted href="/top". Treat that legacy value as a scroll
72
+ // action so it cannot replace the working preview document with a route
73
+ // that does not exist. New generations should use href="#top" instead.
74
+ function normalizeLegacyTopLink(event) {
75
+ if (!event || event.defaultPrevented) return;
76
+ if (event.button != null && event.button !== 0) return;
77
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
78
+
79
+ var target = event.target;
80
+ var anchor = target && typeof target.closest === 'function'
81
+ ? target.closest('a[href]')
82
+ : null;
83
+ if (!anchor || typeof anchor.getAttribute !== 'function') return;
84
+
85
+ var href = anchor.getAttribute('href');
86
+ if (href !== '/top' && href !== '/top/') return;
87
+
88
+ event.preventDefault();
89
+ try {
90
+ window.history.replaceState({}, '', '/');
91
+ } catch (_) {}
92
+
93
+ try {
94
+ var root = window.document && window.document.getElementById
95
+ ? window.document.getElementById('top')
96
+ : null;
97
+ if (root && typeof root.scrollIntoView === 'function') {
98
+ root.scrollIntoView({ behavior: 'smooth', block: 'start' });
99
+ } else if (typeof window.scrollTo === 'function') {
100
+ window.scrollTo({ top: 0, behavior: 'smooth' });
101
+ }
102
+ } catch (_) {}
103
+
104
+ scheduleReport('legacy-top-link');
105
+ }
106
+
107
+ instrumentHistory('pushState');
108
+ instrumentHistory('replaceState');
109
+
110
+ window.addEventListener('popstate', function() {
111
+ scheduleReport('popstate');
112
+ });
113
+ window.addEventListener('hashchange', function() {
114
+ scheduleReport('hashchange');
115
+ });
116
+ window.addEventListener('pageshow', function() {
117
+ scheduleReport('pageshow');
118
+ });
119
+
120
+ if (window.document && typeof window.document.addEventListener === 'function') {
121
+ window.document.addEventListener('click', normalizeLegacyTopLink);
122
+ }
123
+
124
+ reportLocation('initial');
125
+ })();`;
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import vm from "node:vm";
4
+ import { ERROR_BRIDGE_SCRIPT } from "../dist/scripts/error-bridge.js";
5
+
6
+ function createErrorBridgeRuntime() {
7
+ const listeners = new Map();
8
+ const messages = [];
9
+ const root = { children: [], textContent: "" };
10
+ const location = {
11
+ href: "https://workspace.preview.adaptar.dev/?__adaptarOperation=op-1",
12
+ };
13
+ const document = {
14
+ documentElement: { clientWidth: 1280, scrollWidth: 1280 },
15
+ getElementById(id) {
16
+ return id === "root" ? root : null;
17
+ },
18
+ querySelectorAll() {
19
+ return [];
20
+ },
21
+ };
22
+ const window = {
23
+ location,
24
+ innerWidth: 1280,
25
+ parent: {
26
+ postMessage(message) {
27
+ messages.push(JSON.parse(JSON.stringify(message)));
28
+ },
29
+ },
30
+ addEventListener(type, listener) {
31
+ const registered = listeners.get(type) || [];
32
+ registered.push(listener);
33
+ listeners.set(type, registered);
34
+ },
35
+ setInterval() {
36
+ return 1;
37
+ },
38
+ };
39
+ window.window = window;
40
+
41
+ vm.runInNewContext(ERROR_BRIDGE_SCRIPT, {
42
+ URL,
43
+ clearTimeout() {},
44
+ console: { error() {} },
45
+ document,
46
+ location,
47
+ requestAnimationFrame() {},
48
+ setTimeout() {
49
+ return 1;
50
+ },
51
+ window,
52
+ });
53
+
54
+ return {
55
+ messages,
56
+ dispatchResourceError(target) {
57
+ for (const listener of listeners.get("error") || []) {
58
+ listener({ target, srcElement: target });
59
+ }
60
+ },
61
+ };
62
+ }
63
+
64
+ test("ignores URL-less inline module resource events", () => {
65
+ const runtime = createErrorBridgeRuntime();
66
+
67
+ runtime.dispatchResourceError({
68
+ tagName: "SCRIPT",
69
+ type: "module",
70
+ src: "",
71
+ href: "",
72
+ currentSrc: "",
73
+ });
74
+
75
+ assert.deepEqual(runtime.messages, []);
76
+ });
77
+
78
+ test("still reports an external module resource failure with its URL", () => {
79
+ const runtime = createErrorBridgeRuntime();
80
+ const src = "https://workspace.preview.adaptar.dev/src/main.tsx";
81
+
82
+ runtime.dispatchResourceError({
83
+ tagName: "SCRIPT",
84
+ type: "module",
85
+ src,
86
+ href: "",
87
+ currentSrc: src,
88
+ });
89
+
90
+ assert.equal(runtime.messages.length, 1);
91
+ assert.equal(runtime.messages[0].type, "error");
92
+ assert.equal(runtime.messages[0].error.message, `Failed to load module: ${src}`);
93
+ assert.equal(runtime.messages[0].error.resourceKind, "module");
94
+ });
@@ -0,0 +1,147 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import vm from "node:vm";
4
+ import { NAVIGATION_BRIDGE_SCRIPT } from "../dist/scripts/navigation-bridge.js";
5
+
6
+ function createPreviewRuntime(initialUrl) {
7
+ const messages = [];
8
+ const listeners = new Map();
9
+ const documentListeners = new Map();
10
+ const location = { href: initialUrl };
11
+ const parent = {
12
+ postMessage(message) {
13
+ messages.push(JSON.parse(JSON.stringify(message)));
14
+ },
15
+ };
16
+ const history = {
17
+ pushState(_state, _unused, nextUrl) {
18
+ location.href = new URL(String(nextUrl), location.href).toString();
19
+ },
20
+ replaceState(_state, _unused, nextUrl) {
21
+ location.href = new URL(String(nextUrl), location.href).toString();
22
+ },
23
+ };
24
+ const document = {
25
+ addEventListener(type, listener) {
26
+ const registered = documentListeners.get(type) || [];
27
+ registered.push(listener);
28
+ documentListeners.set(type, registered);
29
+ },
30
+ getElementById() {
31
+ return null;
32
+ },
33
+ };
34
+ const scrollCalls = [];
35
+ const window = {
36
+ location,
37
+ parent,
38
+ history,
39
+ document,
40
+ scrollTo(options) {
41
+ scrollCalls.push(options);
42
+ },
43
+ addEventListener(type, listener) {
44
+ const registered = listeners.get(type) || [];
45
+ registered.push(listener);
46
+ listeners.set(type, registered);
47
+ },
48
+ };
49
+ window.window = window;
50
+
51
+ vm.runInNewContext(NAVIGATION_BRIDGE_SCRIPT, {
52
+ Promise,
53
+ URL,
54
+ queueMicrotask(callback) {
55
+ callback();
56
+ },
57
+ window,
58
+ });
59
+
60
+ return {
61
+ history,
62
+ messages,
63
+ navigateByBrowser(nextUrl, eventType) {
64
+ location.href = new URL(nextUrl, location.href).toString();
65
+ for (const listener of listeners.get(eventType) || []) {
66
+ listener();
67
+ }
68
+ },
69
+ clickLegacyTopLink() {
70
+ let prevented = false;
71
+ const anchor = {
72
+ getAttribute(name) {
73
+ return name === "href" ? "/top" : null;
74
+ },
75
+ };
76
+ anchor.closest = () => anchor;
77
+ for (const listener of documentListeners.get("click") || []) {
78
+ listener({
79
+ target: anchor,
80
+ button: 0,
81
+ preventDefault() {
82
+ prevented = true;
83
+ },
84
+ });
85
+ }
86
+ return { prevented, scrollCalls };
87
+ },
88
+ };
89
+ }
90
+
91
+ test("reports initial and History API navigation without internal query state", () => {
92
+ const runtime = createPreviewRuntime(
93
+ "https://workspace.preview.adaptar.dev/?__adaptarOperation=operation-1",
94
+ );
95
+
96
+ assert.deepEqual(runtime.messages[0], {
97
+ source: "adaptar-preview",
98
+ type: "preview-navigation",
99
+ operationId: "operation-1",
100
+ reason: "initial",
101
+ location: {
102
+ pathname: "/",
103
+ search: "",
104
+ hash: "",
105
+ },
106
+ });
107
+
108
+ runtime.history.pushState({}, "", "/work?filter=featured#project");
109
+
110
+ assert.deepEqual(runtime.messages[1], {
111
+ source: "adaptar-preview",
112
+ type: "preview-navigation",
113
+ operationId: "operation-1",
114
+ reason: "pushState",
115
+ location: {
116
+ pathname: "/work",
117
+ search: "?filter=featured",
118
+ hash: "#project",
119
+ },
120
+ });
121
+ });
122
+
123
+ test("reports browser history and suppresses duplicate locations", () => {
124
+ const runtime = createPreviewRuntime(
125
+ "https://workspace.preview.adaptar.dev/studio",
126
+ );
127
+
128
+ runtime.history.replaceState({}, "", "/studio");
129
+ assert.equal(runtime.messages.length, 1);
130
+
131
+ runtime.navigateByBrowser("/contact", "popstate");
132
+ assert.equal(runtime.messages.length, 2);
133
+ assert.equal(runtime.messages[1].location.pathname, "/contact");
134
+ assert.equal(runtime.messages[1].reason, "popstate");
135
+ });
136
+
137
+ test("normalizes legacy /top logo links into a scroll-to-root action", () => {
138
+ const runtime = createPreviewRuntime(
139
+ "https://workspace.preview.adaptar.dev/work?__adaptarOperation=operation-1",
140
+ );
141
+
142
+ const result = runtime.clickLegacyTopLink();
143
+
144
+ assert.equal(result.prevented, true);
145
+ assert.equal(new URL(runtime.messages.at(-1).location.pathname, "https://preview").pathname, "/");
146
+ assert.equal(runtime.messages.at(-1).reason, "replaceState");
147
+ });