Skip to content

Commit 24e8e02

Browse files
CopilotElMassimo
andauthored
feat: add static listener diagnostics and runtime listener guard
Agent-Logs-Url: https://github.com/ElMassimo/iles/sessions/64e6d452-d6f0-447a-9605-2933041a844f Co-authored-by: ElMassimo <1158253+ElMassimo@users.noreply.github.com>
1 parent 35722be commit 24e8e02

11 files changed

Lines changed: 305 additions & 4 deletions

File tree

packages/iles/jsx-runtime.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import {
55
createStaticVNode as raw,
66
Fragment,
77
} from 'vue'
8+
import { guardListenerCall } from './dist/client/app/listenerGuard'
89

910
// Internal: Compatibility layer with the automatic JSX runtime of React.
1011
//
1112
// NOTE: Supports v-slots for consistency with @vue/babel-plugin-jsx.
1213
function jsx (type, { children, 'v-slots': vSlots, ...props }) {
14+
wrapListeners(props, type)
1315
let slots
1416

1517
if (children) {
@@ -31,6 +33,23 @@ function jsx (type, { children, 'v-slots': vSlots, ...props }) {
3133
return createVNode(type, props, slots)
3234
}
3335

36+
function wrapListeners (props, type) {
37+
if (!props || typeof window === 'undefined') return
38+
39+
for (const [key, value] of Object.entries(props)) {
40+
if (!/^on[A-Z]/.test(key) || typeof value !== 'function') continue
41+
props[key] = (...args) => guardListenerCall(
42+
() => value(...args),
43+
args[0],
44+
{
45+
source: 'mdx',
46+
event: key.slice(2).toLowerCase(),
47+
tag: typeof type === 'string' ? type : type?.name || 'Component',
48+
},
49+
)
50+
}
51+
}
52+
3453
// Internal: Extends it to be a stateful component that can perform prop checks.
3554
function defineComponent (MDXContent, definition) {
3655
return defineVueComponent({

packages/iles/src/client/app/components/DebugPanel.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default defineComponent({
1111
const content = ref<any>(null)
1212
const open = ref(false)
1313
const buttonLabel = computed(() => message.value || 'Debug')
14+
const listenerWarnings = computed(() => (window as any).__ILE_DEVTOOLS__?.getListenerWarnings?.() || [])
1415
1516
const cleanPage = computed(() => {
1617
const layout = page.value.layoutName || 'false'
@@ -54,6 +55,7 @@ export default defineComponent({
5455
cleanPage,
5556
copyAll,
5657
content,
58+
listenerWarnings,
5759
}
5860
},
5961
})
@@ -63,6 +65,9 @@ export default defineComponent({
6365
<div ref="el" class="debug" :class="{ open }" @click="open = !open" @mouseup="copyIfSelected">
6466
<p class="title">{{ buttonLabel }}<span class="info">Open DevTools to inspect <b>islands</b> 🏝</span></p>
6567
<pre ref="content" class="block">{{ cleanPage }}</pre>
68+
<pre v-if="listenerWarnings.length" class="block warning">
69+
{{ listenerWarnings.map((item: any) => item.message).join('\n\n') }}
70+
</pre>
6671
<button v-show="open" class="debug title" @click="copyAll(content)">Copy to Clipboard</button>
6772
</div>
6873
</template>
@@ -160,4 +165,10 @@ export default defineComponent({
160165
.block + .block {
161166
margin-top: 8px;
162167
}
168+
169+
.warning {
170+
border-top-color: #EF4444;
171+
color: #FCA5A5;
172+
white-space: pre-wrap;
173+
}
163174
</style>

packages/iles/src/client/app/composables/devtools.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const HYDRATION_LAYER_ID = 'iles:hydration'
1616
let lastUsedIslandId = 0
1717
const islandsById = reactive<Record<string, ComponentPublicInstance>>({})
1818
const islands = computed(() => Object.values(islandsById))
19+
const listenerWarnings = reactive<any[]>([])
1920

2021
const strategyLabels: Record<string, any> = {
2122
'client:idle': 'whenIdle',
@@ -88,6 +89,15 @@ const devtools = {
8889
console.info(`🏝 hydrated ${component}`, el, slots)
8990
}
9091
},
92+
93+
reportStaticListenerWarning (warning: any) {
94+
if (listenerWarnings.some(item => item.key === warning.key)) return
95+
listenerWarnings.push({ time: Date.now(), ...warning })
96+
},
97+
98+
getListenerWarnings () {
99+
return listenerWarnings
100+
},
91101
}
92102

93103
;(window as any).__ILE_DEVTOOLS__ = devtools

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createApp as createClientApp, createSSRApp, ref } from 'vue'
22
import { createMemoryHistory, createRouter as createVueRouter, createWebHistory } from 'vue-router'
33
import { createHead } from '@unhead/vue'
4+
import './listenerGuard'
45

56
import routes from '@islands/routes'
67
import config from '@islands/app-config'
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
interface ListenerWarningDetails {
2+
source?: string
3+
event?: string
4+
tag?: string
5+
line?: number
6+
column?: number
7+
}
8+
9+
type DevtoolsApi = {
10+
reportStaticListenerWarning?: (payload: any) => void
11+
}
12+
13+
const warned = new Set<string>()
14+
15+
function getEventTarget (event: any) {
16+
const target = event?.composedPath?.()?.[0] || event?.target
17+
return target as Element | null
18+
}
19+
20+
function hasIslandContext (target: Element | null) {
21+
return Boolean(target?.closest?.('ile-root[hydrated]'))
22+
}
23+
24+
export function guardListenerCall<T> (handler: () => T, event: Event, details: ListenerWarningDetails = {}): T {
25+
const target = getEventTarget(event)
26+
if (!hasIslandContext(target)) {
27+
const key = [
28+
details.source || 'listener',
29+
details.event || event?.type || 'event',
30+
details.tag || target?.nodeName || 'unknown',
31+
details.line || 0,
32+
details.column || 0,
33+
].join(':')
34+
35+
if (!warned.has(key)) {
36+
warned.add(key)
37+
const location = details.line ? `:${details.line}:${details.column || 0}` : ''
38+
const message = `[iles] Event listener '${details.event || event?.type || 'event'}' on <${details.tag || 'unknown'}>${location} ran without island context. Wrap this component in <Island client:...> or move interaction into an island.`
39+
console.error(message, { details, event, target })
40+
;(window as any).__ILE_DEVTOOLS__?.reportStaticListenerWarning?.({ key, message, details, target, eventType: event?.type })
41+
}
42+
}
43+
44+
return handler()
45+
}
46+
47+
if (typeof window !== 'undefined')
48+
(window as any).__ILE_GUARD_LISTENER_CALL__ = guardListenerCall
49+

packages/iles/src/node/plugin/plugin.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,23 @@ export default function IslandsPlugins (appConfig: AppConfig): PluginOption[] {
134134
if (query.vue !== undefined && query.type === 'script-client')
135135
return 'export default {}; if (import.meta.hot) import.meta.hot.accept()'
136136

137-
if (isSFCMain(path, query) && code.includes('client:') && code.includes('<template'))
138-
return wrapIslandsInSFC(appConfig, code, path)
137+
const sfcMain = isSFCMain(path, query)
138+
const isPageOrLayout = isLayout(path) || plugins.pages.api.isPage(path)
139+
const shouldProcessListeners = !isBuild && isPageOrLayout
140+
141+
if (sfcMain && code.includes('client:') && code.includes('<template'))
142+
return wrapIslandsInSFC(appConfig, code, path, {
143+
analyzeListeners: shouldProcessListeners,
144+
wrapListeners: shouldProcessListeners,
145+
warn: ({ message }) => console.warn(message),
146+
})
147+
148+
if (sfcMain && shouldProcessListeners && code.includes('<template') && (code.includes('@') || code.includes('v-on:')))
149+
return wrapIslandsInSFC(appConfig, code, path, {
150+
analyzeListeners: true,
151+
wrapListeners: true,
152+
warn: ({ message }) => console.warn(message),
153+
})
139154
},
140155
},
141156
{

packages/iles/src/node/plugin/remarkWrapIslands.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export default ({ config }: { config: AppConfig }) => async (ast: any, file: any
2626
wrapWithIsland(strategy, node, resolveComponentImport)
2727
return SKIP
2828
}
29+
30+
if (isJsxElement(node))
31+
warnStaticListeners(node, file.path)
2932
})
3033

3134
const componentsToImport = await Promise.all(componentPromises)
@@ -42,6 +45,18 @@ export default ({ config }: { config: AppConfig }) => async (ast: any, file: any
4245
}
4346
}
4447

48+
function warnStaticListeners (node: MdxJsxFlowElement | MdxJsxTextElement, filename: string) {
49+
const tagName = node.name || 'Component'
50+
for (const attr of node.attributes) {
51+
if ('name' in attr && /^on[A-Z]/.test(String(attr.name))) {
52+
const point = (attr as any).position?.start || (node as any).position?.start
53+
const line = point?.line || 0
54+
const column = point?.column || 0
55+
console.warn(`[iles] Listener '${attr.name}' on <${tagName}> in ${filename}:${line}:${column} will be static at runtime unless wrapped in <Island client:...>.`)
56+
}
57+
}
58+
}
59+
4560
function isJsxElement (node: Node): node is MdxJsxFlowElement | MdxJsxTextElement {
4661
return node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement'
4762
}

packages/iles/src/node/plugin/wrap.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import MagicString from 'magic-string'
22
import type { SFCBlock } from 'vue/compiler-sfc'
33
import { parse } from 'vue/compiler-sfc'
44
import type { ComponentInfo, PublicPluginAPI as ComponentsApi } from 'unplugin-vue-components/types'
5-
import type { ElementNode, RootNode, TemplateChildNode } from '@vue/compiler-core'
5+
import type { DirectiveNode, ElementNode, RootNode, TemplateChildNode } from '@vue/compiler-core'
66
import type { AppConfig } from '../shared'
77
import { pascalCase, isString, debug } from './utils'
88
import { parseImports, parseExports } from './parse'
@@ -15,6 +15,20 @@ interface SfcRootNode extends RootNode {
1515

1616
export const unresolvedIslandKey = '__viteIslandComponent'
1717

18+
export interface ListenerWarning {
19+
event: string
20+
tag: string
21+
line: number
22+
column: number
23+
message: string
24+
}
25+
26+
export interface WrapIslandsOptions {
27+
analyzeListeners?: boolean
28+
wrapListeners?: boolean
29+
warn?: (warning: ListenerWarning) => void
30+
}
31+
1832
export async function wrapLayout (code: string, filename: string) {
1933
const { descriptor: { template }, errors } = parse(code, { filename })
2034
if (errors.length > 0 || !template || !isString(template.attrs.layout)) return
@@ -38,7 +52,7 @@ export async function wrapLayout (code: string, filename: string) {
3852

3953
const scriptClientRE = /<script\b([^>]*\bclient:[^>]*)>([^]*?)<\/script>/
4054

41-
export async function wrapIslandsInSFC (config: AppConfig, code: string, filename: string) {
55+
export async function wrapIslandsInSFC (config: AppConfig, code: string, filename: string, options: WrapIslandsOptions = {}) {
4256
code = code.replace(scriptClientRE, (_, attrs, content) =>
4357
`<script-client${attrs}>${content}</script-client>`)
4458

@@ -69,6 +83,9 @@ export async function wrapIslandsInSFC (config: AppConfig, code: string, filenam
6983

7084
const elements = sfcRootNode.children.filter((n: any) => n.tag) as ElementNode[]
7185

86+
if (options.analyzeListeners || options.wrapListeners)
87+
processSFCListeners(elements, s, filename, options)
88+
7289
for (const child of elements) {
7390
await visitSFCNode(child, s, resolveComponentImport)
7491
}
@@ -97,6 +114,59 @@ export async function wrapIslandsInSFC (config: AppConfig, code: string, filenam
97114
}
98115
}
99116

117+
function processSFCListeners (elements: ElementNode[], s: MagicString, filename: string, options: WrapIslandsOptions) {
118+
const walk = (node: ElementNode, insideIsland = false) => {
119+
const hasClientDirective = node.props.some(prop => 'name' in prop && prop.name.startsWith('client:'))
120+
const withinIsland = insideIsland || hasClientDirective || node.tag === 'Island'
121+
122+
for (const prop of node.props) {
123+
if (!isOnDirective(prop)) continue
124+
const event = getEventName(prop)
125+
const location = prop.loc.start
126+
const warning: ListenerWarning = {
127+
event,
128+
tag: node.tag,
129+
line: location.line,
130+
column: location.column,
131+
message: `[iles] Listener '${event}' on <${node.tag}> in ${filename}:${location.line}:${location.column} will be static at runtime unless wrapped in <Island client:...>.`,
132+
}
133+
134+
if (!withinIsland && options.analyzeListeners)
135+
options.warn?.(warning)
136+
137+
if (options.wrapListeners && prop.exp?.loc?.source)
138+
s.overwrite(prop.exp.loc.start.offset, prop.exp.loc.end.offset, guardedVueExpression(prop.exp.loc.source, warning))
139+
}
140+
141+
for (const child of node.children || []) {
142+
if ('tag' in (child as any))
143+
walk(child as ElementNode, withinIsland)
144+
}
145+
}
146+
147+
for (const element of elements)
148+
walk(element)
149+
}
150+
151+
function guardedVueExpression (expression: string, warning: ListenerWarning) {
152+
const details = JSON.stringify({
153+
source: 'vue',
154+
...warning,
155+
})
156+
const run = `{ const __ile_handler = (${expression}); return typeof __ile_handler === 'function' ? __ile_handler($event) : __ile_handler }`
157+
return `($event) => (window.__ILE_GUARD_LISTENER_CALL__ ? window.__ILE_GUARD_LISTENER_CALL__(() => ${run}, $event, ${details}) : (() => ${run})())`
158+
}
159+
160+
function isOnDirective (prop: any): prop is DirectiveNode {
161+
return prop?.type === 7 && prop?.name === 'on'
162+
}
163+
164+
function getEventName (prop: DirectiveNode) {
165+
if (prop.arg?.type === 4)
166+
return prop.arg.content || 'event'
167+
return 'event'
168+
}
169+
100170
async function visitSFCNode (node: ElementNode, s: MagicString, resolveComponentImport: (strategy: string, tag: string) => Promise<ComponentInfo>) {
101171
const strategy = 'props' in node
102172
&& node.props.find(prop => prop.name.startsWith('client:'))?.name
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, test } from 'vite-plus/test'
2+
3+
import { guardListenerCall } from '@client/app/listenerGuard'
4+
5+
describe('listener guard', () => {
6+
test('calls original handler when no island context and reports once', () => {
7+
const errors: any[] = []
8+
const previousConsoleError = console.error
9+
console.error = (...args: any[]) => { errors.push(args) }
10+
;(globalThis as any).window = {
11+
__ILE_DEVTOOLS__: { reportStaticListenerWarning: () => {} },
12+
}
13+
14+
const event = { type: 'click', target: { nodeName: 'BUTTON', closest: () => null } } as any
15+
let called = 0
16+
guardListenerCall(() => ++called, event, { source: 'vue', event: 'click', tag: 'button', line: 1, column: 2 })
17+
guardListenerCall(() => ++called, event, { source: 'vue', event: 'click', tag: 'button', line: 1, column: 2 })
18+
19+
console.error = previousConsoleError
20+
expect(called).toBe(2)
21+
expect(errors.length).toBe(1)
22+
})
23+
24+
test('does not report when inside an island', () => {
25+
const errors: any[] = []
26+
const previousConsoleError = console.error
27+
console.error = (...args: any[]) => { errors.push(args) }
28+
;(globalThis as any).window = {
29+
__ILE_DEVTOOLS__: { reportStaticListenerWarning: () => {} },
30+
}
31+
32+
const event = { type: 'click', target: { closest: () => ({}) } } as any
33+
const result = guardListenerCall(() => 42, event, { source: 'mdx', event: 'click', tag: 'button' })
34+
35+
console.error = previousConsoleError
36+
expect(result).toBe(42)
37+
expect(errors.length).toBe(0)
38+
})
39+
})
40+
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, test } from 'vite-plus/test'
2+
import type { AppConfig } from 'iles'
3+
4+
import remarkWrapIslands from '@node/plugin/remarkWrapIslands'
5+
6+
describe('remarkWrapIslands listener diagnostics', () => {
7+
test('warns for mdx listeners outside islands', async () => {
8+
const warnings: string[] = []
9+
const previousWarn = console.warn
10+
console.warn = (message: any) => warnings.push(String(message))
11+
12+
const plugin = remarkWrapIslands({
13+
config: {
14+
namedPlugins: { components: { api: {} } },
15+
} as any as AppConfig,
16+
})
17+
18+
const ast: any = {
19+
type: 'root',
20+
children: [{
21+
type: 'mdxJsxFlowElement',
22+
name: 'button',
23+
position: { start: { line: 2, column: 3 } },
24+
attributes: [{ type: 'mdxJsxAttribute', name: 'onClick', position: { start: { line: 2, column: 10 } } }],
25+
}],
26+
}
27+
await plugin(ast, { path: '/src/pages/demo.mdx' })
28+
29+
console.warn = previousWarn
30+
expect(warnings[0]).toContain("Listener 'onClick'")
31+
expect(warnings[0]).toContain('/src/pages/demo.mdx')
32+
})
33+
})
34+

0 commit comments

Comments
 (0)