diff --git a/.changeset/honest-impalas-walk.md b/.changeset/honest-impalas-walk.md new file mode 100644 index 000000000000..d0a329aef0d2 --- /dev/null +++ b/.changeset/honest-impalas-walk.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fix apps being able to crash the dev toolbar in certain cases diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index c1f79ab582df..8a26ebaba6cd 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -2702,7 +2702,7 @@ export interface ClientDirectiveConfig { export interface DevToolbarApp { id: string; name: string; - icon: Icon; + icon?: Icon; init?(canvas: ShadowRoot, eventTarget: EventTarget): void | Promise; beforeTogglingOff?(canvas: ShadowRoot): boolean | Promise; } diff --git a/packages/astro/src/core/logger/core.ts b/packages/astro/src/core/logger/core.ts index f2230f754296..3f9c9f4177b5 100644 --- a/packages/astro/src/core/logger/core.ts +++ b/packages/astro/src/core/logger/core.ts @@ -27,6 +27,7 @@ export type LoggerLabel = | 'middleware' | 'preferences' | 'redirects' + | 'toolbar' // SKIP_FORMAT: A special label that tells the logger not to apply any formatting. // Useful for messages that are already formatted, like the server start message. | 'SKIP_FORMAT'; diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts index 7e7fa5e3a594..d6a2cbde8320 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts @@ -26,7 +26,7 @@ const settingsRows = [ settings.updateSetting('disableAppNotification', evt.currentTarget.checked); const action = evt.currentTarget.checked ? 'disabled' : 'enabled'; - settings.log(`App notification badges ${action}`); + settings.logger.verboseLog(`App notification badges ${action}`); } }, }, @@ -39,7 +39,7 @@ const settingsRows = [ if (evt.currentTarget instanceof HTMLInputElement) { settings.updateSetting('verbose', evt.currentTarget.checked); const action = evt.currentTarget.checked ? 'enabled' : 'disabled'; - settings.log(`Verbose logging ${action}`); + settings.logger.verboseLog(`Verbose logging ${action}`); } }, }, diff --git a/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts b/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts index a1d5484e3ac2..6a62b8416612 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts @@ -201,7 +201,7 @@ document.addEventListener('DOMContentLoaded', async () => { const iconContainer = document.createElement('div'); const iconElement = document.createElement('template'); - iconElement.innerHTML = getAppIcon(app.icon); + iconElement.innerHTML = app.icon ? getAppIcon(app.icon) : '?'; iconContainer.append(iconElement.content.cloneNode(true)); const notification = document.createElement('div'); diff --git a/packages/astro/src/runtime/client/dev-toolbar/settings.ts b/packages/astro/src/runtime/client/dev-toolbar/settings.ts index 6e3656dca32c..ee7386d4f565 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/settings.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/settings.ts @@ -44,6 +44,13 @@ function getSettings() { return _settings; }, updateSetting, - log, + logger: { + log, + verboseLog: (message: string) => { + if (_settings.verbose) { + log(message); + } + }, + }, }; } diff --git a/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts b/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts index 8e1c714014d6..1c9257cf8897 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts @@ -126,6 +126,11 @@ export class AstroDevToolbar extends HTMLElement { outline-offset: -3px; } + #dev-bar #bar-container .item[data-app-error]:hover, #dev-bar #bar-container .item[data-app-error]:focus-visible { + cursor: not-allowed; + background: #ff252520; + } + #dev-bar .item:first-of-type { border-top-left-radius: 9999px; border-bottom-left-radius: 9999px; @@ -166,6 +171,10 @@ export class AstroDevToolbar extends HTMLElement { border-top: 5px solid #343841; } + #dev-bar .item[data-app-error] .icon { + opacity: 0.35; + } + #dev-bar .item:hover .item-tooltip, #dev-bar .item:not(.active):focus-visible .item-tooltip { transition: opacity 0.2s ease-in-out 200ms; opacity: 1; @@ -266,7 +275,7 @@ export class AstroDevToolbar extends HTMLElement { // Create app canvases this.apps.forEach(async (app) => { - if (settings.config.verbose) console.log(`Creating app canvas for ${app.id}`); + settings.logger.verboseLog(`Creating app canvas for ${app.id}`); const appCanvas = document.createElement('astro-dev-toolbar-app-canvas'); appCanvas.dataset.appId = app.id; this.shadowRoot?.append(appCanvas); @@ -353,24 +362,40 @@ export class AstroDevToolbar extends HTMLElement { const shadowRoot = this.getAppCanvasById(app.id)!.shadowRoot!; app.status = 'loading'; try { - if (settings.config.verbose) console.info(`Initializing app ${app.id}`); + settings.logger.verboseLog(`Initializing app ${app.id}`); await app.init?.(shadowRoot, app.eventTarget); app.status = 'ready'; if (import.meta.hot) { import.meta.hot.send(`${WS_EVENT_NAME}:${app.id}:initialized`); + // TODO: Remove in Astro 5.0 import.meta.hot.send(`${WS_EVENT_NAME_DEPRECATED}:${app.id}:initialized`); } } catch (e) { console.error(`Failed to init app ${app.id}, error: ${e}`); app.status = 'error'; + + if (import.meta.hot) { + import.meta.hot.send('astro:devtoolbar:error:init', { + app: app, + error: e instanceof Error ? e.stack : e, + }); + } + + const appButton = this.getAppButtonById(app.id); + const appTooltip = appButton?.querySelector('.item-tooltip'); + + if (appButton && appTooltip) { + appButton.toggleAttribute('data-app-error', true); + appTooltip.innerText = `Error initializing ${app.name}`; + } } } getAppTemplate(app: DevToolbarApp) { return ``; } @@ -385,6 +410,10 @@ export class AstroDevToolbar extends HTMLElement { ); } + getAppButtonById(id: string) { + return this.shadowRoot.querySelector(`[data-app-id="${id}"]`); + } + async toggleAppStatus(app: DevToolbarApp) { const activeApp = this.getActiveApp(); if (activeApp) { @@ -418,7 +447,7 @@ export class AstroDevToolbar extends HTMLElement { } app.active = newStatus ?? !app.active; - const mainBarButton = this.shadowRoot.querySelector(`[data-app-id="${app.id}"]`); + const mainBarButton = this.getAppButtonById(app.id); const moreBarButton = this.getAppCanvasById('astro:more')?.shadowRoot?.querySelector( `[data-app-id="${app.id}"]` ); diff --git a/packages/astro/src/vite-plugin-dev-toolbar/vite-plugin-dev-toolbar.ts b/packages/astro/src/vite-plugin-dev-toolbar/vite-plugin-dev-toolbar.ts index 4e9526993ca6..022a8586c87e 100644 --- a/packages/astro/src/vite-plugin-dev-toolbar/vite-plugin-dev-toolbar.ts +++ b/packages/astro/src/vite-plugin-dev-toolbar/vite-plugin-dev-toolbar.ts @@ -4,7 +4,7 @@ import type { AstroPluginOptions } from '../@types/astro.js'; const VIRTUAL_MODULE_ID = 'astro:dev-toolbar'; const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; -export default function astroDevToolbar({ settings }: AstroPluginOptions): vite.Plugin { +export default function astroDevToolbar({ settings, logger }: AstroPluginOptions): vite.Plugin { return { name: 'astro:dev-toolbar', config() { @@ -20,14 +20,55 @@ export default function astroDevToolbar({ settings }: AstroPluginOptions): vite. return resolvedVirtualModuleId; } }, + configureServer(server) { + server.ws.on('astro:devtoolbar:error:load', (args) => { + logger.error( + 'toolbar', + `Failed to load dev toolbar app from ${args.entrypoint}: ${args.error}` + ); + }); + + server.ws.on('astro:devtoolbar:error:init', (args) => { + logger.error( + 'toolbar', + `Failed to initialize dev toolbar app ${args.app.name} (${args.app.id}):\n${args.error}` + ); + }); + }, async load(id) { if (id === resolvedVirtualModuleId) { + // TODO: In Astro 5.0, we should change the addDevToolbarApp function to separate the logic from the app's metadata. + // That way, we can pass the app's data to the dev toolbar without having to load the app's entrypoint, which will allow + // for a better UI in the browser where we could still show the app's name and icon even if the app's entrypoint fails to load. + // ex: `addDevToolbarApp({ id: 'astro:dev-toolbar:app', name: 'App', icon: '🚀', entrypoint: "./src/something.ts" })` return ` export const loadDevToolbarApps = async () => { - return [${settings.devToolbarApps - .map((plugin) => `(await import(${JSON.stringify(plugin)})).default`) - .join(',')}]; + return (await Promise.all([${settings.devToolbarApps + .map((plugin) => `safeLoadPlugin(${JSON.stringify(plugin)})`) + .join(',')}])).filter(app => app); }; + + async function safeLoadPlugin(entrypoint) { + try { + const app = (await import(/* @vite-ignore */ entrypoint)).default; + + if (typeof app !== 'object' || !app.id || !app.name) { + throw new Error("Apps must default export an object with an id, and a name."); + } + + return app; + } catch (err) { + console.error(\`Failed to load dev toolbar app from \${entrypoint}: \${err.message}\`); + + if (import.meta.hot) { + import.meta.hot.send('astro:devtoolbar:error:load', { entrypoint: entrypoint, error: err.message }) + } + + return undefined; + } + + return undefined; + } `; } },