From 59f093a249052fa84c7f342c060429ee7f4906a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 23:20:22 +0000 Subject: [PATCH] feat: warn when event listeners are used outside islands in dev mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In production, non-island content is static HTML with no JavaScript, so event listeners silently fail. In dev mode however, the full Vue app is hydrated and events work normally — making this bug invisible. This adds a dev-only Vue compiler directiveTransform that wraps all v-on handlers with a runtime check. When an event fires on an element that is NOT inside an (island boundary), the original handler still executes but a loud console.error and visual overlay warn the developer that the handler won't work in production. - Compiler transform wraps Vue's built-in transformOn to inject the wrapper call with source file and line info - Runtime wrapper checks DOM ancestry (closest('ile-root')) to detect island context - Warning overlay renders at the top of the viewport with details - Internal iles components (DebugPanel) are excluded from wrapping - Only active in development mode; zero impact on production builds https://claude.ai/code/session_01PYu7eZm5oRpbEazNxR4YMZ --- .../app/composables/eventListenerWarning.ts | 138 ++++++++++++++++++ packages/iles/src/client/app/index.ts | 4 + packages/iles/src/node/config.ts | 5 + .../src/node/plugin/wrapEventListeners.ts | 47 ++++++ 4 files changed, 194 insertions(+) create mode 100644 packages/iles/src/client/app/composables/eventListenerWarning.ts create mode 100644 packages/iles/src/node/plugin/wrapEventListeners.ts diff --git a/packages/iles/src/client/app/composables/eventListenerWarning.ts b/packages/iles/src/client/app/composables/eventListenerWarning.ts new file mode 100644 index 00000000..5719140a --- /dev/null +++ b/packages/iles/src/client/app/composables/eventListenerWarning.ts @@ -0,0 +1,138 @@ +// Dev-only runtime support for detecting event listeners outside islands. +// +// When an event handler fires on an element that is NOT inside an , +// it means the handler won't work in production (where non-island content is +// static HTML with no JavaScript). +// +// This module: +// 1. Exposes window.__iles_wrap_listener used by the compiler transform +// 2. Shows console.error with actionable info +// 3. Renders a persistent warning overlay + +interface Warning { + eventName: string + source: string + element: string +} + +const warned = new Set() +const warnings: Warning[] = [] +let overlayEl: HTMLElement | null = null + +function wrapListener (handler: any, eventName: string, source: string) { + return function wrappedHandler (this: any, ...args: any[]) { + const result = typeof handler === 'function' ? handler.apply(this, args) : handler + + const event = args[0] + if (event instanceof Event) { + const el = (event.currentTarget || event.target) as Element | null + if (el && !el.closest('ile-root')) { + const key = `${source}::${eventName}` + if (!warned.has(key)) { + warned.add(key) + const tagName = el.tagName.toLowerCase() + + console.error( + `[iles] Event "${eventName}" on <${tagName}> in ${source} is outside an island.\n` + + 'This handler will NOT work in production because non-island content is static HTML.\n' + + 'Fix: wrap the interactive part in a component with a client: directive (e.g. client:load).\n' + + 'Docs: https://iles-docs.netlify.app/guide/hydration', + ) + + warnings.push({ eventName, source, element: `<${tagName}>` }) + renderOverlay() + } + } + } + + return result + } +} + +function renderOverlay () { + if (!overlayEl) { + overlayEl = document.createElement('div') + overlayEl.id = 'iles-event-warning-overlay' + document.body.appendChild(overlayEl) + } + + const count = warnings.length + const details = warnings + .map(w => `
  • ${w.eventName} on ${w.element} in ${w.source}
  • `) + .join('') + + overlayEl.innerHTML = ` + +
    + + ⚠ ${count} event listener${count > 1 ? 's' : ''} outside islands (won't work in production) + + +
      ${details}
    +
    + ` +} + +export function installEventListenerWarning () { + ;(window as any).__iles_wrap_listener = wrapListener +} diff --git a/packages/iles/src/client/app/index.ts b/packages/iles/src/client/app/index.ts index d175c9e7..c13a3a32 100644 --- a/packages/iles/src/client/app/index.ts +++ b/packages/iles/src/client/app/index.ts @@ -90,6 +90,10 @@ if (!import.meta.env.SSR) { const devtools = await import('./composables/devtools') devtools.installDevtools(app, config) + + const { installEventListenerWarning } = await import('./composables/eventListenerWarning') + installEventListenerWarning() + Object.assign(window, { __ILES_PAGE_UPDATE__: forcePageUpdate }) await router.isReady() // wait until page component is fetched before mounting diff --git a/packages/iles/src/node/config.ts b/packages/iles/src/node/config.ts index 352368d8..506cf55c 100644 --- a/packages/iles/src/node/config.ts +++ b/packages/iles/src/node/config.ts @@ -26,6 +26,7 @@ import type { } from './shared' import { camelCase, compact, importLibrary, isString, isStringPlugin, tryImportOrInstallModule, uncapitalize } from './plugin/utils' +import { transformOnWithIslandCheck } from './plugin/wrapEventListeners' import { DIST_CLIENT_PATH, HYDRATION_DIST_PATH, ISLAND_COMPONENT_PATH, resolveAliases } from './alias' import remarkWrapIslands from './plugin/remarkWrapIslands' @@ -135,6 +136,10 @@ async function setNamedPlugins (config: AppConfig, env: ConfigEnv, plugins: Name config.vue.template!.compilerOptions!.isCustomElement = (tagName: string) => tagName.startsWith('ile-') || ceChecks.some(fn => fn!(tagName)) + // In development, wrap all v-on handlers to warn when used outside islands. + if (env.mode === 'development') + config.vue.template!.compilerOptions!.directiveTransforms = { ...config.vue.template!.compilerOptions!.directiveTransforms, on: transformOnWithIslandCheck } + plugins.components = components(config.components) plugins.vue = vue(config.vue) diff --git a/packages/iles/src/node/plugin/wrapEventListeners.ts b/packages/iles/src/node/plugin/wrapEventListeners.ts new file mode 100644 index 00000000..b275220e --- /dev/null +++ b/packages/iles/src/node/plugin/wrapEventListeners.ts @@ -0,0 +1,47 @@ +import { NodeTypes, createCompoundExpression } from '@vue/compiler-core' +import { DOMDirectiveTransforms } from '@vue/compiler-dom' +import type { DirectiveTransform } from '@vue/compiler-core' + +// Dev-only Vue compiler directiveTransform that wraps v-on event handlers +// with a runtime check for island context. +// +// Wraps Vue's built-in transformOn: after it converts @click="handler" into +// { onClick: handler }, this transform rewrites each handler value to: +// window.__iles_wrap_listener(handler, "onClick", "src/pages/foo.vue:12") +// +// The runtime wrapper (installed in dev) calls the original handler normally +// but shows a loud error if the element is outside an . + +const originalTransformOn = DOMDirectiveTransforms.on + +export const transformOnWithIslandCheck: DirectiveTransform = (dir, node, context, augmentor) => { + const result = originalTransformOn(dir, node, context, augmentor) + + // Skip internal iles components (e.g. DebugPanel has legitimate non-island events) + const filename = (context as any).filename || '' + if (filename.includes('iles/src/client/') || filename.includes('iles/dist/client/')) + return result + + if (result.props && result.props.length > 0) { + const prettyFilename = filename.includes('src/') + ? filename.slice(filename.indexOf('src/')) + : filename + + for (const prop of result.props) { + if (prop.type !== NodeTypes.JS_PROPERTY) continue + const key = prop.key + if (key.type !== NodeTypes.SIMPLE_EXPRESSION) continue + if (!key.content.startsWith('on') || key.content.length < 3) continue + + const line = dir.loc?.start?.line || 0 + + prop.value = createCompoundExpression([ + 'window.__iles_wrap_listener(', + prop.value, + `, ${JSON.stringify(key.content)}, ${JSON.stringify(`${prettyFilename}:${line}`)})`, + ]) + } + } + + return result +}