diff --git a/e2e/tests/output-structure.test.ts b/e2e/tests/output-structure.test.ts index 5fe98fd1..8756ccfb 100644 --- a/e2e/tests/output-structure.test.ts +++ b/e2e/tests/output-structure.test.ts @@ -56,16 +56,12 @@ describe('Output Directory Structure', () => { ================================================================================ .output/chrome-mv3/content-scripts/one.js ---------------------------------------- - (function(){\\"use strict\\";function c(n){return n}const e=\\"\\",t={matches:[\\"*://*/*\\"],main:()=>{}};(async()=>{try{await t.main()}catch(n){console.error(\`The content script crashed on startup! - - \`,n)}})()})(); + (function(){\\"use strict\\";function i(n){return n}const s=\\"\\",o={matches:[\\"*://*/*\\"],main:()=>{}};function t(n,...e){if(typeof e[0]==\\"string\\"){const c=e.shift();n(\`[wxt] \${c}\`,...e)}else n(\\"[wxt]\\",...e)}var r={debug:(...n)=>t(console.debug,...n),log:(...n)=>t(console.log,...n),warn:(...n)=>t(console.warn,...n),error:(...n)=>t(console.error,...n)};(async()=>{try{await o.main()}catch(n){r.error(\\"The content script crashed on startup!\\",n)}})()})(); ================================================================================ .output/chrome-mv3/content-scripts/two.js ---------------------------------------- - (function(){\\"use strict\\";function c(n){return n}const e=\\"\\",t={matches:[\\"*://*/*\\"],main:()=>{}};(async()=>{try{await t.main()}catch(n){console.error(\`The content script crashed on startup! - - \`,n)}})()})(); + (function(){\\"use strict\\";function i(n){return n}const s=\\"\\",o={matches:[\\"*://*/*\\"],main:()=>{}};function t(n,...e){if(typeof e[0]==\\"string\\"){const c=e.shift();n(\`[wxt] \${c}\`,...e)}else n(\\"[wxt]\\",...e)}var r={debug:(...n)=>t(console.debug,...n),log:(...n)=>t(console.log,...n),warn:(...n)=>t(console.warn,...n),error:(...n)=>t(console.error,...n)};(async()=>{try{await o.main()}catch(n){r.error(\\"The content script crashed on startup!\\",n)}})()})(); ================================================================================ .output/chrome-mv3/manifest.json diff --git a/scripts/build.ts b/scripts/build.ts index 86007fa2..8d560c05 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -39,16 +39,24 @@ await Promise.all([ silent: true, }), ...clientTemplates.map((templateName) => - tsup.build({ + tsup.build({ entry: { [`templates/virtual-${templateName}`]: `src/client/templates/virtual-${templateName}.ts`, }, + format: ['esm'], + sourcemap: true, + silent: true, + external: [`virtual:user-${templateName}`], + }), + ), + tsup.build({ + entry: { + 'templates/reload-html': `src/client/templates/reload-html.ts`, + }, format: ['esm'], sourcemap: true, silent: true, - external: [`virtual:user-${templateName}`], - }), - ), + }), ]).catch((err) => { spinner.fail(); console.error(err); diff --git a/src/client/templates/reload-html.ts b/src/client/templates/reload-html.ts new file mode 100644 index 00000000..6e383a06 --- /dev/null +++ b/src/client/templates/reload-html.ts @@ -0,0 +1,20 @@ +/// + +import { logger } from '../utils/logger'; +import { setupWebSocket } from '../utils/setupWebSocket'; + +if (__COMMAND__ === 'serve') { + try { + setupWebSocket((message) => { + if (message.event === 'wxt:reload-page') { + // We need to remove the initial slash from the path to compare correctly + // "popup.html" === "/popup.html".substring(1) + if (message.data === location.pathname.substring(1)) { + location.reload(); + } + } + }); + } catch (err) { + logger.error('Failed to setup web socket connection with dev server', err); + } +} diff --git a/src/client/templates/virtual-content-script.ts b/src/client/templates/virtual-content-script.ts index 4efa3e1d..ae221f6f 100644 --- a/src/client/templates/virtual-content-script.ts +++ b/src/client/templates/virtual-content-script.ts @@ -1,9 +1,10 @@ import definition from 'virtual:user-content-script'; +import { logger } from '../utils/logger'; (async () => { try { await definition.main(); } catch (err) { - console.error('The content script crashed on startup!\n\n', err); + logger.error('The content script crashed on startup!', err); } })(); diff --git a/src/client/utils/setupWebSocket.ts b/src/client/utils/setupWebSocket.ts index 81eb3a99..55c93214 100644 --- a/src/client/utils/setupWebSocket.ts +++ b/src/client/utils/setupWebSocket.ts @@ -33,7 +33,6 @@ export function setupWebSocket( try { const message = JSON.parse(e.data) as WebSocketMessage; if (message.type === 'custom' && message.event?.startsWith?.('wxt:')) { - logger.debug(`Recieved message: ${message.event}`, message); onMessage?.(message); } } catch (err) { diff --git a/src/core/types/external.ts b/src/core/types/external.ts index bd7a7ddd..9780d892 100644 --- a/src/core/types/external.ts +++ b/src/core/types/external.ts @@ -58,6 +58,18 @@ export interface WxtDevServer extends vite.ViteDevServer { * Tell the extension to reload by running `browser.runtime.reload`. */ reloadExtension: () => void; + /** + * Tell an extension page to reload. + * + * The path is the bundle path, not the input paths, so if the input paths is + * "src/options/index.html", you would pass "options.html" because that's where it is written to + * in the dist directory, and where it's available at in the actual extension. + * + * @example + * server.reloadPage("popup.html") + * server.reloadPage("sandbox.html") + */ + reloadPage: (path: string) => void; } export type TargetBrowser = 'chrome' | 'firefox' | 'safari' | 'edge' | 'opera'; diff --git a/src/core/utils/detectDevChanges.ts b/src/core/utils/detectDevChanges.ts index 1547ee36..909a1416 100644 --- a/src/core/utils/detectDevChanges.ts +++ b/src/core/utils/detectDevChanges.ts @@ -60,6 +60,17 @@ export function detectDevChanges( } } + const isOnlyHtmlChanges = !changedFiles.find( + ([_, file]) => !file.endsWith('.html'), + ); + if (isOnlyHtmlChanges) { + return { + type: 'html-reload', + cachedOutput: unchangedOutput, + rebuildGroups: changedOutput.steps.map((step) => step.entrypoints), + }; + } + return { type: 'extension-reload', cachedOutput: unchangedOutput, @@ -103,10 +114,7 @@ function findEffectedSteps( * Contains information about what files changed, what needs rebuilt, and the type of reload that is * required. */ -export type DevModeChange = - | NoChange - // | HtmlReload - | ExtensionReload; +export type DevModeChange = NoChange | HtmlReload | ExtensionReload; // | BrowserRestart // | ContentScriptReload @@ -125,15 +133,9 @@ interface RebuildChange { cachedOutput: BuildOutput; } -/** - * This is separate from `ExtensionReload` because if an HTML file changes, for some browsers, - * reloading the current page is enough to pull from the latest change. Simply reloading the page is - * less destructive then reloading the entire extension (content scripts aren't invalidated, tabs to - * extension pages aren't closed, etc). - */ -// interface HtmlReload extends RebuildChange { -// type: 'html-reload'; -// } +interface HtmlReload extends RebuildChange { + type: 'html-reload'; +} interface ExtensionReload extends RebuildChange { type: 'extension-reload'; diff --git a/src/core/vite-plugins/devHtmlPrerender.ts b/src/core/vite-plugins/devHtmlPrerender.ts index 2c81396f..ff1da7b5 100644 --- a/src/core/vite-plugins/devHtmlPrerender.ts +++ b/src/core/vite-plugins/devHtmlPrerender.ts @@ -11,6 +11,21 @@ export function devHtmlPrerender(config: InternalConfig): vite.Plugin { return { apply: 'build', name: 'wxt:dev-html-prerender', + config(userConfig) { + return vite.mergeConfig( + { + resolve: { + alias: { + '@wxt/reload-html': resolve( + config.root, + 'node_modules/wxt/dist/templates/reload-html.js', + ), + }, + }, + }, + userConfig, + ); + }, async transform(html, id) { const server = config.server; if (config.command !== 'serve' || server == null || !id.endsWith('.html')) @@ -43,6 +58,12 @@ export function devHtmlPrerender(config: InternalConfig): vite.Plugin { pointToDevServer('script[type=module]', 'src'); pointToDevServer('link[rel=stylesheet]', 'href'); + // Add a script to add page reloading + const reloader = document.createElement('script'); + reloader.src = '@wxt/reload-html'; + reloader.type = 'module'; + document.head.appendChild(reloader); + const newHtml = document.toString(); config.logger.debug('Transformed ' + id); config.logger.debug('Old HTML:\n' + html); diff --git a/src/index.ts b/src/index.ts index 2ec0a842..410e4ed6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,10 @@ import { detectDevChanges } from './core/utils/detectDevChanges'; import { Mutex } from 'async-mutex'; import { consola } from 'consola'; import { relative } from 'node:path'; -import { getEntrypointOutputFile } from './core/utils/entrypoints'; +import { + getEntrypointBundlePath, + getEntrypointOutputFile, +} from './core/utils/entrypoints'; export { version } from '../package.json'; export * from './core/types/external'; @@ -106,6 +109,17 @@ export async function createServer( server.reloadExtension(); consola.success(`Reloaded extension: ${rebuiltNames}`); break; + case 'html-reload': + changes.rebuildGroups.flat().forEach((entry) => { + const path = getEntrypointBundlePath( + entry, + internalConfig.outDir, + '.html', + ); + server.reloadPage(path); + }); + consola.success(`Reloaded pages: ${rebuiltNames}`); + break; } }); }); @@ -130,6 +144,11 @@ export async function createServer( reloadExtension: () => { server.ws.send('wxt:reload-extension'); }, + reloadPage: (path) => { + // Can't use Vite's built-in "full-reload" event because it doesn't like our paths, it expects + // paths ending in "/index.html" + server.ws.send('wxt:reload-page', path); + }, }; internalConfig.logger.info('Created dev server');