diff --git a/packages/fresh/src/build_cache.ts b/packages/fresh/src/build_cache.ts index a63cbc5510e..e99f0ebc628 100644 --- a/packages/fresh/src/build_cache.ts +++ b/packages/fresh/src/build_cache.ts @@ -35,6 +35,8 @@ export interface BuildCache { root: string; islandRegistry: ServerIslandRegistry; clientEntry: string; + /** Pathname for the HMR-only chunk (development only). Undefined in production. */ + hmrClientEntry?: string; features: { errorOverlay: boolean; }; diff --git a/packages/fresh/src/context.ts b/packages/fresh/src/context.ts index b89e21a2c07..782beb2a9ef 100644 --- a/packages/fresh/src/context.ts +++ b/packages/fresh/src/context.ts @@ -357,25 +357,34 @@ export class Context { } throw err; } finally { - // Add preload headers + // Add preload headers only when client JS is actually emitted. const basePath = this.config.basePath; - const runtimeUrl = state.buildCache.clientEntry.startsWith(".") - ? state.buildCache.clientEntry.slice(1) - : state.buildCache.clientEntry; - let link = `<${ - encodeURI(`${basePath}${runtimeUrl}`) - }>; rel="modulepreload"; as="script"`; - state.islands.forEach((island) => { - const specifier = `${basePath}${ - island.file.startsWith(".") ? island.file.slice(1) : island.file - }`; - link += `, <${ - encodeURI(specifier) - }>; rel="modulepreload"; as="script"`; - }); - - if (link !== "") { - headers.append("Link", link); + const linkParts: string[] = []; + + if ( + state.needsClientRuntime || + state.buildCache.hmrClientEntry !== undefined + ) { + const runtimeUrl = state.buildCache.clientEntry.startsWith(".") + ? state.buildCache.clientEntry.slice(1) + : state.buildCache.clientEntry; + linkParts.push( + `<${ + encodeURI(`${basePath}${runtimeUrl}`) + }>; rel="modulepreload"; as="script"`, + ); + state.islands.forEach((island) => { + const specifier = `${basePath}${ + island.file.startsWith(".") ? island.file.slice(1) : island.file + }`; + linkParts.push( + `<${encodeURI(specifier)}>; rel="modulepreload"; as="script"`, + ); + }); + } + + if (linkParts.length > 0) { + headers.append("Link", linkParts.join(", ")); } renderNonce = state.nonce; diff --git a/packages/fresh/src/dev/builder.ts b/packages/fresh/src/dev/builder.ts index 723a2e9d538..69b3711583c 100644 --- a/packages/fresh/src/dev/builder.ts +++ b/packages/fresh/src/dev/builder.ts @@ -314,6 +314,13 @@ export class Builder { "fresh-runtime": new URL(runtimePath, import.meta.url).href, }; + if (dev) { + entryPoints["fresh-hmr"] = new URL( + "../runtime/client/dev_hmr.ts", + import.meta.url, + ).href; + } + const namer = new UniqueNamer(); for (const spec of this.#islandSpecifiers) { const specName = specToName(spec); @@ -353,6 +360,13 @@ export class Builder { buildCache.islandModNameToChunk.get(name)!.browser = pathname; } + if (dev) { + const hmrChunkName = output.entryToChunk.get("fresh-hmr"); + if (hmrChunkName !== undefined) { + buildCache.hmrClientEntry = `${prefix}${hmrChunkName}`; + } + } + for (let i = 0; i < output.files.length; i++) { const file = output.files[i]; const pathname = `${prefix}${file.path}`; diff --git a/packages/fresh/src/dev/dev_build_cache.ts b/packages/fresh/src/dev/dev_build_cache.ts index 2414886274c..b34098b55fd 100644 --- a/packages/fresh/src/dev/dev_build_cache.ts +++ b/packages/fresh/src/dev/dev_build_cache.ts @@ -63,6 +63,7 @@ export class MemoryBuildCache implements DevBuildCache { root: string; islandRegistry: ServerIslandRegistry = new Map(); clientEntry: string; + hmrClientEntry: string | undefined = undefined; features = { errorOverlay: false }; constructor( @@ -236,6 +237,7 @@ export class DiskBuildCache implements DevBuildCache { root: string; islandRegistry: ServerIslandRegistry = new Map(); clientEntry: string = ""; + hmrClientEntry: string | undefined = undefined; features = { errorOverlay: false }; constructor( diff --git a/packages/fresh/src/runtime/client/dev.ts b/packages/fresh/src/runtime/client/dev.ts index 8f993518c59..f96f681c82d 100644 --- a/packages/fresh/src/runtime/client/dev.ts +++ b/packages/fresh/src/runtime/client/dev.ts @@ -1,136 +1,3 @@ import "preact/debug"; export * from "./mod.ts"; -import { IS_BROWSER } from "../shared.ts"; - -let ws: WebSocket; -let revision = 0; - -let reconnectTimer: number; -const backoff = [ - // Wait 100ms initially, because we could also be - // disconnected because of a form submit. - 100, - 150, - 200, - 250, - 300, - 350, - 400, - 450, - 500, - 500, - 605, - 750, - 1000, - 1250, - 1500, - 1750, - 2000, -]; -let backoffIdx = 0; -function reconnect() { - if (ws.readyState !== ws.CLOSED) return; - - reconnectTimer = setTimeout(() => { - if (backoffIdx === 0) { - // deno-lint-ignore no-console - console.log( - `%c Fresh %c Connection closed. Trying to reconnect...`, - "background-color: #86efac; color: black", - "color: inherit", - ); - } - backoffIdx++; - - try { - connect(); - clearTimeout(reconnectTimer); - } catch (_err) { - reconnect(); - } - }, backoff[Math.min(backoffIdx, backoff.length - 1)]); -} - -function onOpenWs() { - backoffIdx = 0; -} - -function onCloseWs() { - disconnect(); - reconnect(); -} - -function connect() { - const url = new URL("/_frsh/alive", location.origin.replace("http", "ws")); - ws = new WebSocket( - url, - ); - - ws.addEventListener("open", onOpenWs); - ws.addEventListener("close", onCloseWs); - ws.addEventListener("message", handleMessage); - ws.addEventListener("error", handleError); -} - -function disconnect() { - ws.removeEventListener("open", onOpenWs); - ws.removeEventListener("close", onCloseWs); - ws.removeEventListener("message", handleMessage); - ws.removeEventListener("error", handleError); - ws.close(); -} - -function handleMessage(e: MessageEvent) { - const data = JSON.parse(e.data); - switch (data.type) { - case "initial-state": { - if (revision === 0) { - // deno-lint-ignore no-console - console.log( - `%c Fresh %c Connected to development server.`, - "background-color: #86efac; color: black", - "color: inherit", - ); - } - - if (revision === 0) { - revision = data.revision; - } else if (revision < data.revision) { - disconnect(); - // Needs reload - location.reload(); - } - } - } -} - -function handleError(e: Event) { - // TODO - // deno-lint-ignore no-explicit-any - if (e && (e as any).code === "ECONNREFUSED") { - setTimeout(connect, 1000); - } -} - -if (IS_BROWSER) { - connect(); - - addEventListener("message", (ev) => { - if (ev.origin !== location.origin) return; - if (typeof ev.data !== "string" || ev.data !== "close-error-overlay") { - return; - } - - document.querySelector("#fresh-error-overlay")?.remove(); - }); - - // Disconnect when the tab becomes inactive and re-connect when it - // becomes active again - addEventListener("visibilitychange", () => { - if (document.hidden) { - disconnect(); - } else { - connect(); - } - }); -} +export * from "./dev_hmr.ts"; diff --git a/packages/fresh/src/runtime/client/dev_hmr.ts b/packages/fresh/src/runtime/client/dev_hmr.ts new file mode 100644 index 00000000000..b86c98e0358 --- /dev/null +++ b/packages/fresh/src/runtime/client/dev_hmr.ts @@ -0,0 +1,134 @@ +import { IS_BROWSER } from "../shared.ts"; + +let ws: WebSocket; +let revision = 0; + +let reconnectTimer: number; +const backoff = [ + // Wait 100ms initially, because we could also be + // disconnected because of a form submit. + 100, + 150, + 200, + 250, + 300, + 350, + 400, + 450, + 500, + 500, + 605, + 750, + 1000, + 1250, + 1500, + 1750, + 2000, +]; +let backoffIdx = 0; +function reconnect() { + if (ws.readyState !== ws.CLOSED) return; + + reconnectTimer = setTimeout(() => { + if (backoffIdx === 0) { + // deno-lint-ignore no-console + console.log( + `%c Fresh %c Connection closed. Trying to reconnect...`, + "background-color: #86efac; color: black", + "color: inherit", + ); + } + backoffIdx++; + + try { + connect(); + clearTimeout(reconnectTimer); + } catch (_err) { + reconnect(); + } + }, backoff[Math.min(backoffIdx, backoff.length - 1)]); +} + +function onOpenWs() { + backoffIdx = 0; +} + +function onCloseWs() { + disconnect(); + reconnect(); +} + +function connect() { + const url = new URL("/_frsh/alive", location.origin.replace("http", "ws")); + ws = new WebSocket( + url, + ); + + ws.addEventListener("open", onOpenWs); + ws.addEventListener("close", onCloseWs); + ws.addEventListener("message", handleMessage); + ws.addEventListener("error", handleError); +} + +function disconnect() { + ws.removeEventListener("open", onOpenWs); + ws.removeEventListener("close", onCloseWs); + ws.removeEventListener("message", handleMessage); + ws.removeEventListener("error", handleError); + ws.close(); +} + +function handleMessage(e: MessageEvent) { + const data = JSON.parse(e.data); + switch (data.type) { + case "initial-state": { + if (revision === 0) { + // deno-lint-ignore no-console + console.log( + `%c Fresh %c Connected to development server.`, + "background-color: #86efac; color: black", + "color: inherit", + ); + } + + if (revision === 0) { + revision = data.revision; + } else if (revision < data.revision) { + disconnect(); + // Needs reload + location.reload(); + } + } + } +} + +function handleError(e: Event) { + // TODO + // deno-lint-ignore no-explicit-any + if (e && (e as any).code === "ECONNREFUSED") { + setTimeout(connect, 1000); + } +} + +if (IS_BROWSER) { + connect(); + + addEventListener("message", (ev) => { + if (ev.origin !== location.origin) return; + if (typeof ev.data !== "string" || ev.data !== "close-error-overlay") { + return; + } + + document.querySelector("#fresh-error-overlay")?.remove(); + }); + + // Disconnect when the tab becomes inactive and re-connect when it + // becomes active again + addEventListener("visibilitychange", () => { + if (document.hidden) { + disconnect(); + } else { + connect(); + } + }); +} diff --git a/packages/fresh/src/runtime/server/preact_hooks.ts b/packages/fresh/src/runtime/server/preact_hooks.ts index d33d61fee1a..b7ad8f8e733 100644 --- a/packages/fresh/src/runtime/server/preact_hooks.ts +++ b/packages/fresh/src/runtime/server/preact_hooks.ts @@ -82,6 +82,26 @@ export class RenderState { renderedHtmlBody = false; renderedHtmlHead = false; hasRuntimeScript = false; + /** Set to true when any element in the tree renders f-client-nav="true". */ + clientNavEnabled = false; + + /** + * True when the page needs Fresh's client runtime (islands, client nav, or + * `` regions on a full document). Partial subresponses omit boot; + * `encounteredPartials` must not force runtime for those requests. + */ + get needsClientRuntime(): boolean { + if (this.islands.size > 0 || this.clientNavEnabled) { + return true; + } + if ( + !this.ctx.url.searchParams.has(PARTIAL_SEARCH_PARAM) && + this.encounteredPartials.size > 0 + ) { + return true; + } + return false; + } constructor( // deno-lint-ignore no-explicit-any @@ -419,6 +439,22 @@ options[OptionsType.DIFF] = (vnode) => { break; } + // Detect f-client-nav="true" on any element in the rendered tree. + // We check here in the diff hook (not the vnode hook) so we catch both + // VNodes created inside component functions during rendering AND those + // pre-created in route handlers before setRenderState was called. + // + // The === "true" check relies on the vnode hook having normalized boolean + // f-client-nav on string elements via String(...) (see OptionsType.VNODE). + // Preact invokes the vnode hook before diff for a given VNode, so e.g. + // becomes the string "true" before we run here. + if ( + CLIENT_NAV_ATTR in (vnode.props as Record) && + (vnode.props as Record)[CLIENT_NAV_ATTR] === "true" + ) { + RENDER_STATE!.clientNavEnabled = true; + } + if ( vnode.key !== undefined && (RENDER_STATE!.partialDepth > 0 || hasIslandOwner(RENDER_STATE!, vnode)) @@ -623,6 +659,9 @@ function FreshRuntimeScript() { const islandArr = Array.from(islands); + // Partial responses only embed __FRSH_STATE__ JSON for the swapped fragment. + // We do not gate on needsClientRuntime: the parent full-document response is + // responsible for loading the client boot when islands or client nav require it. if (ctx.url.searchParams.has(PARTIAL_SEARCH_PARAM)) { const islands = islandArr.map((island) => { return { @@ -647,7 +686,12 @@ function FreshRuntimeScript() { }, }) ); - } else { + } else if ( + RENDER_STATE!.needsClientRuntime || + buildCache.hmrClientEntry !== undefined + ) { + // Full-document boot: islands / partials / client nav, or Vite/HMR dev + // (client entry must load so e.g. CSS side-effect imports run). const islandImports = islandArr.map((island) => { const named = island.exportName === "default" ? island.name @@ -689,6 +733,9 @@ function FreshRuntimeScript() { ) ); } + + // Production static page: no client JS at all. + return buildCache.features.errorOverlay ? h(ShowErrorOverlay, null) : null; } export function ShowErrorOverlay() { diff --git a/packages/fresh/tests/no_client_js_test.tsx b/packages/fresh/tests/no_client_js_test.tsx new file mode 100644 index 00000000000..6ffab3cdc08 --- /dev/null +++ b/packages/fresh/tests/no_client_js_test.tsx @@ -0,0 +1,181 @@ +/** + * Tests for the "zero client JS by default" feature. + * + * Fresh should not ship any client JavaScript when a page renders no islands + * and has no element with `f-client-nav="true"`. The full Fresh client runtime + * (fresh-runtime.js + inline boot script) must only appear when actually needed. + */ +import { App } from "fresh"; +import { signal } from "@preact/signals"; +import { Counter } from "./fixtures_islands/Counter.tsx"; +import { expect } from "@std/expect"; +import { FakeServer } from "../src/test_utils.ts"; +import { + ALL_ISLAND_DIR, + assertNotSelector, + assertSelector, + buildProd, + Doc, + parseHtml, +} from "./test_utils.tsx"; + +// Build once so all island tests share the same compiled output. +const allIslandCache = await buildProd({ islandDir: ALL_ISLAND_DIR }); + +function islandApp(): App { + const app = new App().get("/", (ctx) => { + const sig = signal(0); + return ctx.render( + + + , + ); + }); + allIslandCache(app); + return app; +} + +// --------------------------------------------------------------------------- +// Static pages (no islands, no f-client-nav) +// --------------------------------------------------------------------------- + +Deno.test("no-client-js - static page emits no module boot script", async () => { + const app = new App().get("/", (ctx) => + ctx.render( + +

Hello

+
, + )); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const html = await res.text(); + const doc = parseHtml(html); + + // No inline module script should be present. + assertNotSelector(doc, 'script[type="module"]'); + // Confirm the page itself rendered fine. + assertSelector(doc, "h1"); + expect(doc.querySelector("h1")?.textContent).toEqual("Hello"); +}); + +Deno.test("no-client-js - static page does not reference fresh-runtime.js", async () => { + const app = new App().get("/", (ctx) => + ctx.render( + +

Hi

+
, + )); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const html = await res.text(); + + expect(html).not.toContain("fresh-runtime"); + expect(html).not.toContain("boot("); +}); + +Deno.test("no-client-js - static page has no Link modulepreload header", async () => { + const app = new App().get("/", (ctx) => + ctx.render( + +

Hi

+
, + )); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + + expect(res.headers.get("Link")).toBeNull(); +}); + +// --------------------------------------------------------------------------- +// Pages with f-client-nav="true" must still load the client runtime. +// f-client-nav is typically placed in an appWrapper or layout component, +// so the VNode is created INSIDE a component that runs during renderToString +// (and therefore sees an active RENDER_STATE). +// --------------------------------------------------------------------------- + +Deno.test("no-client-js - f-client-nav='true' page includes boot script", async () => { + const app = new App() + .appWrapper(({ Component }) => ( + + + + + + + + + )) + .get("/", (ctx) => ctx.render(Link)); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const html = await res.text(); + const doc = parseHtml(html); + + // A module script importing boot must be present. + assertSelector(doc, 'script[type="module"]'); + expect(html).toContain("boot("); +}); + +Deno.test("no-client-js - f-client-nav='false' page emits no boot script", async () => { + const app = new App() + .appWrapper(({ Component }) => ( + + + + + + + + + )) + .get("/", (ctx) => ctx.render(

Static

)); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const html = await res.text(); + + expect(html).not.toContain("boot("); + expect(html).not.toContain("fresh-runtime"); +}); + +// --------------------------------------------------------------------------- +// Pages with islands must still load the client runtime +// --------------------------------------------------------------------------- + +Deno.test("no-client-js - island page includes boot script", async () => { + const app = islandApp(); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const html = await res.text(); + const doc = parseHtml(html); + + assertSelector(doc, 'script[type="module"]'); + expect(html).toContain("boot("); +}); + +Deno.test("no-client-js - island page references fresh-runtime.js", async () => { + const app = islandApp(); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const html = await res.text(); + + expect(html).toContain("fresh-runtime"); +}); + +Deno.test("no-client-js - island page has Link modulepreload header", async () => { + const app = islandApp(); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + + const link = res.headers.get("Link"); + expect(link).not.toBeNull(); + expect(link).toContain("modulepreload"); + expect(link).toContain("fresh-runtime"); +}); diff --git a/packages/plugin-vite/src/plugins/deno.ts b/packages/plugin-vite/src/plugins/deno.ts index 97f6ad7c5e0..1c5f9f6d34e 100644 --- a/packages/plugin-vite/src/plugins/deno.ts +++ b/packages/plugin-vite/src/plugins/deno.ts @@ -302,6 +302,7 @@ function isJsMediaType(media: MediaType): boolean { case MediaType.Unknown: return false; } + return false; } export type DenoSpecifier = string & { __deno: string }; diff --git a/packages/plugin-vite/tests/dev_server_test.ts b/packages/plugin-vite/tests/dev_server_test.ts index 6f446ed310d..b271c9f326a 100644 --- a/packages/plugin-vite/tests/dev_server_test.ts +++ b/packages/plugin-vite/tests/dev_server_test.ts @@ -203,7 +203,10 @@ Deno.test({ waitUntil: "networkidle2", }); - await page.locator("style[data-vite-dev-id$='style.css']").wait(); + // Vite 6: data-vite-dev-id; Vite 7+: vite-module-id on injected style. + await page.locator( + "style[data-vite-dev-id$='style.css'], style[vite-module-id]", + ).wait(); }); }); }, @@ -221,7 +224,9 @@ Deno.test({ waitUntil: "networkidle2", }); - await page.locator("style[data-vite-dev-id$='style.css']").wait(); + await page.locator( + "style[data-vite-dev-id$='style.css'], style[vite-module-id]", + ).wait(); }); }); },