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 +}