diff --git a/packages/iles/jsx-runtime.js b/packages/iles/jsx-runtime.js index 75a6cdc3..2035e3e1 100644 --- a/packages/iles/jsx-runtime.js +++ b/packages/iles/jsx-runtime.js @@ -10,6 +10,7 @@ import { // // NOTE: Supports v-slots for consistency with @vue/babel-plugin-jsx. function jsx (type, { children, 'v-slots': vSlots, ...props }) { + wrapListeners(props, type) let slots if (children) { @@ -31,6 +32,28 @@ function jsx (type, { children, 'v-slots': vSlots, ...props }) { return createVNode(type, props, slots) } +function wrapListeners (props, type) { + if (!props || typeof window === 'undefined') return + + for (const [key, value] of Object.entries(props)) { + if (!/^on[A-Z]/.test(key) || typeof value !== 'function') continue + props[key] = (...args) => guardListenerCall( + () => value(...args), + args[0], + { + source: 'mdx', + event: key.slice(2), + tag: typeof type === 'string' ? type : type?.name || 'Component', + }, + ) + } +} + +function guardListenerCall (handler, event, details) { + const guard = typeof window !== 'undefined' && window.__ILE_GUARD_LISTENER_CALL__ + return guard ? guard(handler, event, details) : handler() +} + // Internal: Extends it to be a stateful component that can perform prop checks. function defineComponent (MDXContent, definition) { return defineVueComponent({ diff --git a/packages/iles/src/client/app/components/DebugPanel.vue b/packages/iles/src/client/app/components/DebugPanel.vue index cba6169b..d5eaea62 100644 --- a/packages/iles/src/client/app/components/DebugPanel.vue +++ b/packages/iles/src/client/app/components/DebugPanel.vue @@ -11,6 +11,8 @@ export default defineComponent({ const content = ref(null) const open = ref(false) const buttonLabel = computed(() => message.value || 'Debug') + const listenerWarnings = computed(() => (window as any).__ILE_DEVTOOLS__?.getListenerWarnings?.() || []) + const formattedListenerWarnings = computed(() => listenerWarnings.value.map((item: any) => item.message).join('\n\n')) const cleanPage = computed(() => { const layout = page.value.layoutName || 'false' @@ -54,6 +56,8 @@ export default defineComponent({ cleanPage, copyAll, content, + listenerWarnings, + formattedListenerWarnings, } }, }) @@ -63,6 +67,9 @@ export default defineComponent({

{{ buttonLabel }}Open DevTools to inspect islands 🏝

{{ cleanPage }}
+
+{{ formattedListenerWarnings }}
+    
@@ -160,4 +167,10 @@ export default defineComponent({ .block + .block { margin-top: 8px; } + +.warning { + border-top-color: #EF4444; + color: #FCA5A5; + white-space: pre-wrap; +} diff --git a/packages/iles/src/client/app/composables/devtools.ts b/packages/iles/src/client/app/composables/devtools.ts index 9a730182..439e46fa 100644 --- a/packages/iles/src/client/app/composables/devtools.ts +++ b/packages/iles/src/client/app/composables/devtools.ts @@ -16,6 +16,7 @@ const HYDRATION_LAYER_ID = 'iles:hydration' let lastUsedIslandId = 0 const islandsById = reactive>({}) const islands = computed(() => Object.values(islandsById)) +const listenerWarnings = reactive([]) const strategyLabels: Record = { 'client:idle': 'whenIdle', @@ -88,6 +89,15 @@ const devtools = { console.info(`🏝 hydrated ${component}`, el, slots) } }, + + reportStaticListenerWarning (warning: any) { + if (listenerWarnings.some(item => item.key === warning.key)) return + listenerWarnings.push({ time: Date.now(), ...warning }) + }, + + getListenerWarnings () { + return listenerWarnings + }, } ;(window as any).__ILE_DEVTOOLS__ = devtools diff --git a/packages/iles/src/client/app/index.ts b/packages/iles/src/client/app/index.ts index d175c9e7..afe6cf2e 100644 --- a/packages/iles/src/client/app/index.ts +++ b/packages/iles/src/client/app/index.ts @@ -1,6 +1,7 @@ import { createApp as createClientApp, createSSRApp, ref } from 'vue' import { createMemoryHistory, createRouter as createVueRouter, createWebHistory } from 'vue-router' import { createHead } from '@unhead/vue' +import './listenerGuard' import routes from '@islands/routes' import config from '@islands/app-config' diff --git a/packages/iles/src/client/app/listenerGuard.ts b/packages/iles/src/client/app/listenerGuard.ts new file mode 100644 index 00000000..9ea3d406 --- /dev/null +++ b/packages/iles/src/client/app/listenerGuard.ts @@ -0,0 +1,44 @@ +interface ListenerWarningDetails { + source?: string + event?: string + tag?: string + line?: number + column?: number +} + +const warned = new Set() + +function getEventTarget (event: any) { + const target = event?.composedPath?.()?.[0] || event?.target + return target as Element | null +} + +function hasIslandContext (target: Element | null) { + return Boolean(target?.closest?.('ile-root[hydrated]')) +} + +export function guardListenerCall (handler: () => T, event: Event, details: ListenerWarningDetails = {}): T { + const target = getEventTarget(event) + if (!hasIslandContext(target)) { + const key = [ + details.source || 'listener', + details.event || event?.type || 'event', + details.tag || target?.nodeName || 'unknown', + details.line || 0, + details.column || 0, + ].join(':') + + if (!warned.has(key)) { + warned.add(key) + const location = details.line ? `:${details.line}:${details.column || 0}` : '' + const message = `[iles] Event listener '${details.event || event?.type || 'event'}' on <${details.tag || 'unknown'}>${location} ran without island context. Wrap this component in or move interaction into an island.` + console.error(message, { details, event, target }) + ;(window as any).__ILE_DEVTOOLS__?.reportStaticListenerWarning?.({ key, message, details, target, eventType: event?.type }) + } + } + + return handler() +} + +if (typeof window !== 'undefined') + (window as any).__ILE_GUARD_LISTENER_CALL__ = guardListenerCall diff --git a/packages/iles/src/node/plugin/plugin.ts b/packages/iles/src/node/plugin/plugin.ts index e3749217..e0757710 100644 --- a/packages/iles/src/node/plugin/plugin.ts +++ b/packages/iles/src/node/plugin/plugin.ts @@ -134,8 +134,23 @@ export default function IslandsPlugins (appConfig: AppConfig): PluginOption[] { if (query.vue !== undefined && query.type === 'script-client') return 'export default {}; if (import.meta.hot) import.meta.hot.accept()' - if (isSFCMain(path, query) && code.includes('client:') && code.includes(' console.warn(message), + }) + + if (sfcMain && shouldProcessListeners && code.includes(' console.warn(message), + }) }, }, { diff --git a/packages/iles/src/node/plugin/remarkWrapIslands.ts b/packages/iles/src/node/plugin/remarkWrapIslands.ts index f9172ffe..6f3cb8bb 100644 --- a/packages/iles/src/node/plugin/remarkWrapIslands.ts +++ b/packages/iles/src/node/plugin/remarkWrapIslands.ts @@ -26,6 +26,9 @@ export default ({ config }: { config: AppConfig }) => async (ast: any, file: any wrapWithIsland(strategy, node, resolveComponentImport) return SKIP } + + if (isJsxElement(node)) + warnStaticListeners(node, file.path) }) const componentsToImport = await Promise.all(componentPromises) @@ -42,6 +45,18 @@ export default ({ config }: { config: AppConfig }) => async (ast: any, file: any } } +function warnStaticListeners (node: MdxJsxFlowElement | MdxJsxTextElement, filename: string) { + const tagName = node.name || 'Component' + for (const attr of node.attributes) { + if ('name' in attr && /^on[A-Z]/.test(String(attr.name))) { + const point = (attr as any).position?.start || (node as any).position?.start + const line = point?.line || 0 + const column = point?.column || 0 + console.warn(`[iles] Listener '${attr.name}' on <${tagName}> in ${filename}:${line}:${column} will be static at runtime unless wrapped in .`) + } + } +} + function isJsxElement (node: Node): node is MdxJsxFlowElement | MdxJsxTextElement { return node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement' } diff --git a/packages/iles/src/node/plugin/wrap.ts b/packages/iles/src/node/plugin/wrap.ts index 48e59d0d..1ea45e24 100644 --- a/packages/iles/src/node/plugin/wrap.ts +++ b/packages/iles/src/node/plugin/wrap.ts @@ -2,7 +2,7 @@ import MagicString from 'magic-string' import type { SFCBlock } from 'vue/compiler-sfc' import { parse } from 'vue/compiler-sfc' import type { ComponentInfo, PublicPluginAPI as ComponentsApi } from 'unplugin-vue-components/types' -import type { ElementNode, RootNode, TemplateChildNode } from '@vue/compiler-core' +import type { DirectiveNode, ElementNode, RootNode, TemplateChildNode } from '@vue/compiler-core' import type { AppConfig } from '../shared' import { pascalCase, isString, debug } from './utils' import { parseImports, parseExports } from './parse' @@ -15,6 +15,20 @@ interface SfcRootNode extends RootNode { export const unresolvedIslandKey = '__viteIslandComponent' +export interface ListenerWarning { + event: string + tag: string + line: number + column: number + message: string +} + +export interface WrapIslandsOptions { + analyzeListeners?: boolean + wrapListeners?: boolean + warn?: (warning: ListenerWarning) => void +} + export async function wrapLayout (code: string, filename: string) { const { descriptor: { template }, errors } = parse(code, { filename }) if (errors.length > 0 || !template || !isString(template.attrs.layout)) return @@ -38,7 +52,7 @@ export async function wrapLayout (code: string, filename: string) { const scriptClientRE = /]*\bclient:[^>]*)>([^]*?)<\/script>/ -export async function wrapIslandsInSFC (config: AppConfig, code: string, filename: string) { +export async function wrapIslandsInSFC (config: AppConfig, code: string, filename: string, options: WrapIslandsOptions = {}) { code = code.replace(scriptClientRE, (_, attrs, content) => `${content}`) @@ -69,6 +83,9 @@ export async function wrapIslandsInSFC (config: AppConfig, code: string, filenam const elements = sfcRootNode.children.filter((n: any) => n.tag) as ElementNode[] + if (options.analyzeListeners || options.wrapListeners) + processSFCListeners(elements, s, filename, options) + for (const child of elements) { await visitSFCNode(child, s, resolveComponentImport) } @@ -97,6 +114,59 @@ export async function wrapIslandsInSFC (config: AppConfig, code: string, filenam } } +function processSFCListeners (elements: ElementNode[], s: MagicString, filename: string, options: WrapIslandsOptions) { + const walk = (node: ElementNode, insideIsland = false) => { + const hasClientDirective = node.props.some(prop => 'name' in prop && prop.name.startsWith('client:')) + const withinIsland = insideIsland || hasClientDirective || node.tag === 'Island' + + for (const prop of node.props) { + if (!isOnDirective(prop)) continue + const event = getEventName(prop) + const location = prop.loc.start + const warning: ListenerWarning = { + event, + tag: node.tag, + line: location.line, + column: location.column, + message: `[iles] Listener '${event}' on <${node.tag}> in ${filename}:${location.line}:${location.column} will be static at runtime unless wrapped in .`, + } + + if (!withinIsland && options.analyzeListeners) + options.warn?.(warning) + + if (options.wrapListeners && prop.exp?.loc?.source) + wrapVueExpression(s, prop.exp.loc.start.offset, prop.exp.loc.end.offset, warning) + } + + for (const child of node.children || []) { + if ('tag' in (child as any)) + walk(child as ElementNode, withinIsland) + } + } + + for (const element of elements) + walk(element) +} + +function wrapVueExpression (s: MagicString, start: number, end: number, warning: ListenerWarning) { + const details = JSON.stringify({ + source: 'vue', + ...warning, + }) + s.appendLeft(start, `($event) => window.__ILE_GUARD_LISTENER_CALL__(() => { const _ileHandler = (`) + s.appendRight(end, `); return typeof _ileHandler === 'function' ? _ileHandler($event) : _ileHandler }, $event, ${details})`) +} + +function isOnDirective (prop: any): prop is DirectiveNode { + return prop?.type === 7 && prop?.name === 'on' +} + +function getEventName (prop: DirectiveNode) { + if (prop.arg?.type === 4) + return prop.arg.content || 'event' + return 'event' +} + async function visitSFCNode (node: ElementNode, s: MagicString, resolveComponentImport: (strategy: string, tag: string) => Promise) { const strategy = 'props' in node && node.props.find(prop => prop.name.startsWith('client:'))?.name diff --git a/packages/iles/tests/listener-guard.spec.ts b/packages/iles/tests/listener-guard.spec.ts new file mode 100644 index 00000000..5b782ff7 --- /dev/null +++ b/packages/iles/tests/listener-guard.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vite-plus/test' + +import { guardListenerCall } from '../src/client/app/listenerGuard' + +describe('listener guard', () => { + test('calls original handler when no island context and reports once', () => { + const errors: any[] = [] + const previousConsoleError = console.error + console.error = (...args: any[]) => { errors.push(args) } + ;(globalThis as any).window = { + __ILE_DEVTOOLS__: { reportStaticListenerWarning: () => {} }, + } + + const event = { type: 'click', target: { nodeName: 'BUTTON', closest: () => null } } as any + let called = 0 + guardListenerCall(() => ++called, event, { source: 'vue', event: 'click', tag: 'button', line: 1, column: 2 }) + guardListenerCall(() => ++called, event, { source: 'vue', event: 'click', tag: 'button', line: 1, column: 2 }) + + console.error = previousConsoleError + expect(called).toBe(2) + expect(errors.length).toBe(1) + }) + + test('does not report when inside an island', () => { + const errors: any[] = [] + const previousConsoleError = console.error + console.error = (...args: any[]) => { errors.push(args) } + ;(globalThis as any).window = { + __ILE_DEVTOOLS__: { reportStaticListenerWarning: () => {} }, + } + + const event = { type: 'click', target: { closest: () => ({}) } } as any + const result = guardListenerCall(() => 42, event, { source: 'mdx', event: 'click', tag: 'button' }) + + console.error = previousConsoleError + expect(result).toBe(42) + expect(errors.length).toBe(0) + }) +}) diff --git a/packages/iles/tests/remark-wrap-islands.spec.ts b/packages/iles/tests/remark-wrap-islands.spec.ts new file mode 100644 index 00000000..f19ca954 --- /dev/null +++ b/packages/iles/tests/remark-wrap-islands.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vite-plus/test' +import type { AppConfig } from 'iles' + +import remarkWrapIslands from '@node/plugin/remarkWrapIslands' + +describe('remarkWrapIslands listener diagnostics', () => { + test('warns for mdx listeners outside islands', async () => { + const warnings: string[] = [] + const previousWarn = console.warn + console.warn = (message: any) => warnings.push(String(message)) + + const plugin = remarkWrapIslands({ + config: { + namedPlugins: { components: { api: {} } }, + } as any as AppConfig, + }) + + const ast: any = { + type: 'root', + children: [{ + type: 'mdxJsxFlowElement', + name: 'button', + position: { start: { line: 2, column: 3 } }, + attributes: [{ type: 'mdxJsxAttribute', name: 'onClick', position: { start: { line: 2, column: 10 } } }], + }], + } + await plugin(ast, { path: '/src/pages/demo.mdx' }) + + console.warn = previousWarn + expect(warnings[0]).toContain("Listener 'onClick'") + expect(warnings[0]).toContain('/src/pages/demo.mdx') + }) +}) diff --git a/packages/iles/tests/wrap.spec.ts b/packages/iles/tests/wrap.spec.ts new file mode 100644 index 00000000..84bedcaa --- /dev/null +++ b/packages/iles/tests/wrap.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vite-plus/test' +import type { AppConfig } from 'iles' + +import { wrapIslandsInSFC } from '@node/plugin/wrap' + +const minimalConfig = { + namedPlugins: { + components: { + api: { + findComponent: async () => ({ from: '/comp.vue', name: 'default' }), + stringifyImport: () => `import __ile_components_0 from '/comp.vue'`, + }, + }, + pages: { api: { isPage: () => true } }, + }, + resolvePath: async (path: string) => path, +} as any as AppConfig + +describe('wrapIslandsInSFC listener diagnostics', () => { + test('warns for listeners outside islands', async () => { + const warnings: string[] = [] + await wrapIslandsInSFC(minimalConfig, ``, '/src/pages/a.vue', { + analyzeListeners: true, + warn: warning => warnings.push(warning.message), + }) + expect(warnings[0]).toContain("Listener 'click'") + expect(warnings[0]).toContain('/src/pages/a.vue') + }) + + test('wraps listener handlers', async () => { + const result = await wrapIslandsInSFC(minimalConfig, ``, '/src/pages/a.vue', { + wrapListeners: true, + }) + expect(result?.code).toContain('__ILE_GUARD_LISTENER_CALL__') + }) +})