Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions packages/iles/src/client/app/composables/eventListenerWarning.ts
Original file line number Diff line number Diff line change
@@ -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 <ile-root>,
// 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<string>()
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 => `<li><code>${w.eventName}</code> on <code>${w.element}</code> in <code>${w.source}</code></li>`)
.join('')

overlayEl.innerHTML = `
<style>
#iles-event-warning-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 99999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
background: #FEF3CD;
color: #664D03;
border-bottom: 2px solid #FFE69C;
padding: 8px 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
#iles-event-warning-overlay summary {
cursor: pointer;
font-weight: 600;
user-select: none;
}
#iles-event-warning-overlay ul {
margin: 6px 0 2px;
padding-left: 20px;
}
#iles-event-warning-overlay li {
margin: 3px 0;
}
#iles-event-warning-overlay code {
background: rgba(0,0,0,0.07);
padding: 1px 4px;
border-radius: 3px;
font-size: 12px;
}
#iles-event-warning-overlay .dismiss {
float: right;
background: none;
border: 1px solid #C6A700;
border-radius: 3px;
color: #664D03;
cursor: pointer;
padding: 2px 8px;
font-size: 12px;
}
#iles-event-warning-overlay .dismiss:hover {
background: rgba(0,0,0,0.05);
}
@media (prefers-color-scheme: dark) {
#iles-event-warning-overlay {
background: #332701;
color: #FFE69C;
border-bottom-color: #664D03;
}
#iles-event-warning-overlay code {
background: rgba(255,255,255,0.1);
}
#iles-event-warning-overlay .dismiss {
border-color: #664D03;
color: #FFE69C;
}
}
</style>
<details open>
<summary>
⚠ ${count} event listener${count > 1 ? 's' : ''} outside islands (won't work in production)
<button class="dismiss" onclick="this.closest('#iles-event-warning-overlay').remove()">Dismiss</button>
</summary>
<ul>${details}</ul>
</details>
`
}

export function installEventListenerWarning () {
;(window as any).__iles_wrap_listener = wrapListener
}
4 changes: 4 additions & 0 deletions packages/iles/src/client/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/iles/src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)

Expand Down
47 changes: 47 additions & 0 deletions packages/iles/src/node/plugin/wrapEventListeners.ts
Original file line number Diff line number Diff line change
@@ -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 <ile-root>.

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