Skip to content

Commit 59f093a

Browse files
committed
feat: warn when event listeners are used outside islands in dev mode
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 <ile-root> (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
1 parent 35722be commit 59f093a

4 files changed

Lines changed: 194 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Dev-only runtime support for detecting event listeners outside islands.
2+
//
3+
// When an event handler fires on an element that is NOT inside an <ile-root>,
4+
// it means the handler won't work in production (where non-island content is
5+
// static HTML with no JavaScript).
6+
//
7+
// This module:
8+
// 1. Exposes window.__iles_wrap_listener used by the compiler transform
9+
// 2. Shows console.error with actionable info
10+
// 3. Renders a persistent warning overlay
11+
12+
interface Warning {
13+
eventName: string
14+
source: string
15+
element: string
16+
}
17+
18+
const warned = new Set<string>()
19+
const warnings: Warning[] = []
20+
let overlayEl: HTMLElement | null = null
21+
22+
function wrapListener (handler: any, eventName: string, source: string) {
23+
return function wrappedHandler (this: any, ...args: any[]) {
24+
const result = typeof handler === 'function' ? handler.apply(this, args) : handler
25+
26+
const event = args[0]
27+
if (event instanceof Event) {
28+
const el = (event.currentTarget || event.target) as Element | null
29+
if (el && !el.closest('ile-root')) {
30+
const key = `${source}::${eventName}`
31+
if (!warned.has(key)) {
32+
warned.add(key)
33+
const tagName = el.tagName.toLowerCase()
34+
35+
console.error(
36+
`[iles] Event "${eventName}" on <${tagName}> in ${source} is outside an island.\n`
37+
+ 'This handler will NOT work in production because non-island content is static HTML.\n'
38+
+ 'Fix: wrap the interactive part in a component with a client: directive (e.g. client:load).\n'
39+
+ 'Docs: https://iles-docs.netlify.app/guide/hydration',
40+
)
41+
42+
warnings.push({ eventName, source, element: `<${tagName}>` })
43+
renderOverlay()
44+
}
45+
}
46+
}
47+
48+
return result
49+
}
50+
}
51+
52+
function renderOverlay () {
53+
if (!overlayEl) {
54+
overlayEl = document.createElement('div')
55+
overlayEl.id = 'iles-event-warning-overlay'
56+
document.body.appendChild(overlayEl)
57+
}
58+
59+
const count = warnings.length
60+
const details = warnings
61+
.map(w => `<li><code>${w.eventName}</code> on <code>${w.element}</code> in <code>${w.source}</code></li>`)
62+
.join('')
63+
64+
overlayEl.innerHTML = `
65+
<style>
66+
#iles-event-warning-overlay {
67+
position: fixed;
68+
top: 0;
69+
left: 0;
70+
right: 0;
71+
z-index: 99999;
72+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
73+
font-size: 13px;
74+
background: #FEF3CD;
75+
color: #664D03;
76+
border-bottom: 2px solid #FFE69C;
77+
padding: 8px 16px;
78+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
79+
}
80+
#iles-event-warning-overlay summary {
81+
cursor: pointer;
82+
font-weight: 600;
83+
user-select: none;
84+
}
85+
#iles-event-warning-overlay ul {
86+
margin: 6px 0 2px;
87+
padding-left: 20px;
88+
}
89+
#iles-event-warning-overlay li {
90+
margin: 3px 0;
91+
}
92+
#iles-event-warning-overlay code {
93+
background: rgba(0,0,0,0.07);
94+
padding: 1px 4px;
95+
border-radius: 3px;
96+
font-size: 12px;
97+
}
98+
#iles-event-warning-overlay .dismiss {
99+
float: right;
100+
background: none;
101+
border: 1px solid #C6A700;
102+
border-radius: 3px;
103+
color: #664D03;
104+
cursor: pointer;
105+
padding: 2px 8px;
106+
font-size: 12px;
107+
}
108+
#iles-event-warning-overlay .dismiss:hover {
109+
background: rgba(0,0,0,0.05);
110+
}
111+
@media (prefers-color-scheme: dark) {
112+
#iles-event-warning-overlay {
113+
background: #332701;
114+
color: #FFE69C;
115+
border-bottom-color: #664D03;
116+
}
117+
#iles-event-warning-overlay code {
118+
background: rgba(255,255,255,0.1);
119+
}
120+
#iles-event-warning-overlay .dismiss {
121+
border-color: #664D03;
122+
color: #FFE69C;
123+
}
124+
}
125+
</style>
126+
<details open>
127+
<summary>
128+
${count} event listener${count > 1 ? 's' : ''} outside islands (won't work in production)
129+
<button class="dismiss" onclick="this.closest('#iles-event-warning-overlay').remove()">Dismiss</button>
130+
</summary>
131+
<ul>${details}</ul>
132+
</details>
133+
`
134+
}
135+
136+
export function installEventListenerWarning () {
137+
;(window as any).__iles_wrap_listener = wrapListener
138+
}

packages/iles/src/client/app/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ if (!import.meta.env.SSR) {
9090

9191
const devtools = await import('./composables/devtools')
9292
devtools.installDevtools(app, config)
93+
94+
const { installEventListenerWarning } = await import('./composables/eventListenerWarning')
95+
installEventListenerWarning()
96+
9397
Object.assign(window, { __ILES_PAGE_UPDATE__: forcePageUpdate })
9498

9599
await router.isReady() // wait until page component is fetched before mounting

packages/iles/src/node/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
} from './shared'
2727

2828
import { camelCase, compact, importLibrary, isString, isStringPlugin, tryImportOrInstallModule, uncapitalize } from './plugin/utils'
29+
import { transformOnWithIslandCheck } from './plugin/wrapEventListeners'
2930
import { DIST_CLIENT_PATH, HYDRATION_DIST_PATH, ISLAND_COMPONENT_PATH, resolveAliases } from './alias'
3031
import remarkWrapIslands from './plugin/remarkWrapIslands'
3132

@@ -135,6 +136,10 @@ async function setNamedPlugins (config: AppConfig, env: ConfigEnv, plugins: Name
135136
config.vue.template!.compilerOptions!.isCustomElement = (tagName: string) =>
136137
tagName.startsWith('ile-') || ceChecks.some(fn => fn!(tagName))
137138

139+
// In development, wrap all v-on handlers to warn when used outside islands.
140+
if (env.mode === 'development')
141+
config.vue.template!.compilerOptions!.directiveTransforms = { ...config.vue.template!.compilerOptions!.directiveTransforms, on: transformOnWithIslandCheck }
142+
138143
plugins.components = components(config.components)
139144
plugins.vue = vue(config.vue)
140145

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { NodeTypes, createCompoundExpression } from '@vue/compiler-core'
2+
import { DOMDirectiveTransforms } from '@vue/compiler-dom'
3+
import type { DirectiveTransform } from '@vue/compiler-core'
4+
5+
// Dev-only Vue compiler directiveTransform that wraps v-on event handlers
6+
// with a runtime check for island context.
7+
//
8+
// Wraps Vue's built-in transformOn: after it converts @click="handler" into
9+
// { onClick: handler }, this transform rewrites each handler value to:
10+
// window.__iles_wrap_listener(handler, "onClick", "src/pages/foo.vue:12")
11+
//
12+
// The runtime wrapper (installed in dev) calls the original handler normally
13+
// but shows a loud error if the element is outside an <ile-root>.
14+
15+
const originalTransformOn = DOMDirectiveTransforms.on
16+
17+
export const transformOnWithIslandCheck: DirectiveTransform = (dir, node, context, augmentor) => {
18+
const result = originalTransformOn(dir, node, context, augmentor)
19+
20+
// Skip internal iles components (e.g. DebugPanel has legitimate non-island events)
21+
const filename = (context as any).filename || ''
22+
if (filename.includes('iles/src/client/') || filename.includes('iles/dist/client/'))
23+
return result
24+
25+
if (result.props && result.props.length > 0) {
26+
const prettyFilename = filename.includes('src/')
27+
? filename.slice(filename.indexOf('src/'))
28+
: filename
29+
30+
for (const prop of result.props) {
31+
if (prop.type !== NodeTypes.JS_PROPERTY) continue
32+
const key = prop.key
33+
if (key.type !== NodeTypes.SIMPLE_EXPRESSION) continue
34+
if (!key.content.startsWith('on') || key.content.length < 3) continue
35+
36+
const line = dir.loc?.start?.line || 0
37+
38+
prop.value = createCompoundExpression([
39+
'window.__iles_wrap_listener(',
40+
prop.value,
41+
`, ${JSON.stringify(key.content)}, ${JSON.stringify(`${prettyFilename}:${line}`)})`,
42+
])
43+
}
44+
}
45+
46+
return result
47+
}

0 commit comments

Comments
 (0)