Skip to content
Draft
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
23 changes: 23 additions & 0 deletions packages/iles/jsx-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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({
Expand Down
13 changes: 13 additions & 0 deletions packages/iles/src/client/app/components/DebugPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export default defineComponent({
const content = ref<any>(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'
Expand Down Expand Up @@ -54,6 +56,8 @@ export default defineComponent({
cleanPage,
copyAll,
content,
listenerWarnings,
formattedListenerWarnings,
}
},
})
Expand All @@ -63,6 +67,9 @@ export default defineComponent({
<div ref="el" class="debug" :class="{ open }" @click="open = !open" @mouseup="copyIfSelected">
<p class="title">{{ buttonLabel }}<span class="info">Open DevTools to inspect <b>islands</b> 🏝</span></p>
<pre ref="content" class="block">{{ cleanPage }}</pre>
<pre v-if="listenerWarnings.length" class="block warning">
{{ formattedListenerWarnings }}
</pre>
<button v-show="open" class="debug title" @click="copyAll(content)">Copy to Clipboard</button>
</div>
</template>
Expand Down Expand Up @@ -160,4 +167,10 @@ export default defineComponent({
.block + .block {
margin-top: 8px;
}

.warning {
border-top-color: #EF4444;
color: #FCA5A5;
white-space: pre-wrap;
}
</style>
10 changes: 10 additions & 0 deletions packages/iles/src/client/app/composables/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const HYDRATION_LAYER_ID = 'iles:hydration'
let lastUsedIslandId = 0
const islandsById = reactive<Record<string, ComponentPublicInstance>>({})
const islands = computed(() => Object.values(islandsById))
const listenerWarnings = reactive<any[]>([])

const strategyLabels: Record<string, any> = {
'client:idle': 'whenIdle',
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/iles/src/client/app/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
44 changes: 44 additions & 0 deletions packages/iles/src/client/app/listenerGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
interface ListenerWarningDetails {
source?: string
event?: string
tag?: string
line?: number
column?: number
}

const warned = new Set<string>()

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<T> (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 <Island client:...> 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
19 changes: 17 additions & 2 deletions packages/iles/src/node/plugin/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<template'))
return wrapIslandsInSFC(appConfig, code, path)
const sfcMain = isSFCMain(path, query)
const isPageOrLayout = isLayout(path) || plugins.pages.api.isPage(path)
const shouldProcessListeners = !isBuild && isPageOrLayout

if (sfcMain && code.includes('client:') && code.includes('<template'))
return wrapIslandsInSFC(appConfig, code, path, {
analyzeListeners: shouldProcessListeners,
wrapListeners: shouldProcessListeners,
warn: ({ message }) => console.warn(message),
})

if (sfcMain && shouldProcessListeners && code.includes('<template') && (code.includes('@') || code.includes('v-on:')))
return wrapIslandsInSFC(appConfig, code, path, {
analyzeListeners: true,
wrapListeners: true,
warn: ({ message }) => console.warn(message),
})
},
},
{
Expand Down
15 changes: 15 additions & 0 deletions packages/iles/src/node/plugin/remarkWrapIslands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 <Island client:...>.`)
}
}
}

function isJsxElement (node: Node): node is MdxJsxFlowElement | MdxJsxTextElement {
return node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement'
}
Expand Down
74 changes: 72 additions & 2 deletions packages/iles/src/node/plugin/wrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -38,7 +52,7 @@ export async function wrapLayout (code: string, filename: string) {

const scriptClientRE = /<script\b([^>]*\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) =>
`<script-client${attrs}>${content}</script-client>`)

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 <Island client:...>.`,
}

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<ComponentInfo>) {
const strategy = 'props' in node
&& node.props.find(prop => prop.name.startsWith('client:'))?.name
Expand Down
39 changes: 39 additions & 0 deletions packages/iles/tests/listener-guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
33 changes: 33 additions & 0 deletions packages/iles/tests/remark-wrap-islands.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading