Skip to content

Commit 6dd2c97

Browse files
Ionarubartlomiejuclaude
authored
feat:Actually ship no JS by default (#3696)
In many places, Fresh marketing claims the framework ships no JS by default, **this was false**. This PR reworks core parts of the framework to check whether JS is needed on the client with these criteria: 1. Use of islands 2. Use of Partials (`f-client-nav`) If either of those those criteria are met, JavaScript will be loaded as normal, but if both are false, then the `client-entry` script is omitted from the page. Fixes #3662 --------- Co-authored-by: Bartek Iwańczuk <biwanczuk@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8b0d42b commit 6dd2c97

10 files changed

Lines changed: 417 additions & 155 deletions

File tree

packages/fresh/src/build_cache.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export interface BuildCache<State = any> {
3535
root: string;
3636
islandRegistry: ServerIslandRegistry;
3737
clientEntry: string;
38+
/** Pathname for the HMR-only chunk (development only). Undefined in production. */
39+
hmrClientEntry?: string;
3840
features: {
3941
errorOverlay: boolean;
4042
};

packages/fresh/src/context.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -357,25 +357,34 @@ export class Context<State> {
357357
}
358358
throw err;
359359
} finally {
360-
// Add preload headers
360+
// Add preload headers only when client JS is actually emitted.
361361
const basePath = this.config.basePath;
362-
const runtimeUrl = state.buildCache.clientEntry.startsWith(".")
363-
? state.buildCache.clientEntry.slice(1)
364-
: state.buildCache.clientEntry;
365-
let link = `<${
366-
encodeURI(`${basePath}${runtimeUrl}`)
367-
}>; rel="modulepreload"; as="script"`;
368-
state.islands.forEach((island) => {
369-
const specifier = `${basePath}${
370-
island.file.startsWith(".") ? island.file.slice(1) : island.file
371-
}`;
372-
link += `, <${
373-
encodeURI(specifier)
374-
}>; rel="modulepreload"; as="script"`;
375-
});
376-
377-
if (link !== "") {
378-
headers.append("Link", link);
362+
const linkParts: string[] = [];
363+
364+
if (
365+
state.needsClientRuntime ||
366+
state.buildCache.hmrClientEntry !== undefined
367+
) {
368+
const runtimeUrl = state.buildCache.clientEntry.startsWith(".")
369+
? state.buildCache.clientEntry.slice(1)
370+
: state.buildCache.clientEntry;
371+
linkParts.push(
372+
`<${
373+
encodeURI(`${basePath}${runtimeUrl}`)
374+
}>; rel="modulepreload"; as="script"`,
375+
);
376+
state.islands.forEach((island) => {
377+
const specifier = `${basePath}${
378+
island.file.startsWith(".") ? island.file.slice(1) : island.file
379+
}`;
380+
linkParts.push(
381+
`<${encodeURI(specifier)}>; rel="modulepreload"; as="script"`,
382+
);
383+
});
384+
}
385+
386+
if (linkParts.length > 0) {
387+
headers.append("Link", linkParts.join(", "));
379388
}
380389

381390
renderNonce = state.nonce;

packages/fresh/src/dev/builder.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,13 @@ export class Builder<State = any> {
314314
"fresh-runtime": new URL(runtimePath, import.meta.url).href,
315315
};
316316

317+
if (dev) {
318+
entryPoints["fresh-hmr"] = new URL(
319+
"../runtime/client/dev_hmr.ts",
320+
import.meta.url,
321+
).href;
322+
}
323+
317324
const namer = new UniqueNamer();
318325
for (const spec of this.#islandSpecifiers) {
319326
const specName = specToName(spec);
@@ -353,6 +360,13 @@ export class Builder<State = any> {
353360
buildCache.islandModNameToChunk.get(name)!.browser = pathname;
354361
}
355362

363+
if (dev) {
364+
const hmrChunkName = output.entryToChunk.get("fresh-hmr");
365+
if (hmrChunkName !== undefined) {
366+
buildCache.hmrClientEntry = `${prefix}${hmrChunkName}`;
367+
}
368+
}
369+
356370
for (let i = 0; i < output.files.length; i++) {
357371
const file = output.files[i];
358372
const pathname = `${prefix}${file.path}`;

packages/fresh/src/dev/dev_build_cache.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export class MemoryBuildCache<State> implements DevBuildCache<State> {
6363
root: string;
6464
islandRegistry: ServerIslandRegistry = new Map();
6565
clientEntry: string;
66+
hmrClientEntry: string | undefined = undefined;
6667
features = { errorOverlay: false };
6768

6869
constructor(
@@ -236,6 +237,7 @@ export class DiskBuildCache<State> implements DevBuildCache<State> {
236237
root: string;
237238
islandRegistry: ServerIslandRegistry = new Map();
238239
clientEntry: string = "";
240+
hmrClientEntry: string | undefined = undefined;
239241
features = { errorOverlay: false };
240242

241243
constructor(
Lines changed: 1 addition & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,136 +1,3 @@
11
import "preact/debug";
22
export * from "./mod.ts";
3-
import { IS_BROWSER } from "../shared.ts";
4-
5-
let ws: WebSocket;
6-
let revision = 0;
7-
8-
let reconnectTimer: number;
9-
const backoff = [
10-
// Wait 100ms initially, because we could also be
11-
// disconnected because of a form submit.
12-
100,
13-
150,
14-
200,
15-
250,
16-
300,
17-
350,
18-
400,
19-
450,
20-
500,
21-
500,
22-
605,
23-
750,
24-
1000,
25-
1250,
26-
1500,
27-
1750,
28-
2000,
29-
];
30-
let backoffIdx = 0;
31-
function reconnect() {
32-
if (ws.readyState !== ws.CLOSED) return;
33-
34-
reconnectTimer = setTimeout(() => {
35-
if (backoffIdx === 0) {
36-
// deno-lint-ignore no-console
37-
console.log(
38-
`%c Fresh %c Connection closed. Trying to reconnect...`,
39-
"background-color: #86efac; color: black",
40-
"color: inherit",
41-
);
42-
}
43-
backoffIdx++;
44-
45-
try {
46-
connect();
47-
clearTimeout(reconnectTimer);
48-
} catch (_err) {
49-
reconnect();
50-
}
51-
}, backoff[Math.min(backoffIdx, backoff.length - 1)]);
52-
}
53-
54-
function onOpenWs() {
55-
backoffIdx = 0;
56-
}
57-
58-
function onCloseWs() {
59-
disconnect();
60-
reconnect();
61-
}
62-
63-
function connect() {
64-
const url = new URL("/_frsh/alive", location.origin.replace("http", "ws"));
65-
ws = new WebSocket(
66-
url,
67-
);
68-
69-
ws.addEventListener("open", onOpenWs);
70-
ws.addEventListener("close", onCloseWs);
71-
ws.addEventListener("message", handleMessage);
72-
ws.addEventListener("error", handleError);
73-
}
74-
75-
function disconnect() {
76-
ws.removeEventListener("open", onOpenWs);
77-
ws.removeEventListener("close", onCloseWs);
78-
ws.removeEventListener("message", handleMessage);
79-
ws.removeEventListener("error", handleError);
80-
ws.close();
81-
}
82-
83-
function handleMessage(e: MessageEvent) {
84-
const data = JSON.parse(e.data);
85-
switch (data.type) {
86-
case "initial-state": {
87-
if (revision === 0) {
88-
// deno-lint-ignore no-console
89-
console.log(
90-
`%c Fresh %c Connected to development server.`,
91-
"background-color: #86efac; color: black",
92-
"color: inherit",
93-
);
94-
}
95-
96-
if (revision === 0) {
97-
revision = data.revision;
98-
} else if (revision < data.revision) {
99-
disconnect();
100-
// Needs reload
101-
location.reload();
102-
}
103-
}
104-
}
105-
}
106-
107-
function handleError(e: Event) {
108-
// TODO
109-
// deno-lint-ignore no-explicit-any
110-
if (e && (e as any).code === "ECONNREFUSED") {
111-
setTimeout(connect, 1000);
112-
}
113-
}
114-
115-
if (IS_BROWSER) {
116-
connect();
117-
118-
addEventListener("message", (ev) => {
119-
if (ev.origin !== location.origin) return;
120-
if (typeof ev.data !== "string" || ev.data !== "close-error-overlay") {
121-
return;
122-
}
123-
124-
document.querySelector("#fresh-error-overlay")?.remove();
125-
});
126-
127-
// Disconnect when the tab becomes inactive and re-connect when it
128-
// becomes active again
129-
addEventListener("visibilitychange", () => {
130-
if (document.hidden) {
131-
disconnect();
132-
} else {
133-
connect();
134-
}
135-
});
136-
}
3+
export * from "./dev_hmr.ts";
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { IS_BROWSER } from "../shared.ts";
2+
3+
let ws: WebSocket;
4+
let revision = 0;
5+
6+
let reconnectTimer: number;
7+
const backoff = [
8+
// Wait 100ms initially, because we could also be
9+
// disconnected because of a form submit.
10+
100,
11+
150,
12+
200,
13+
250,
14+
300,
15+
350,
16+
400,
17+
450,
18+
500,
19+
500,
20+
605,
21+
750,
22+
1000,
23+
1250,
24+
1500,
25+
1750,
26+
2000,
27+
];
28+
let backoffIdx = 0;
29+
function reconnect() {
30+
if (ws.readyState !== ws.CLOSED) return;
31+
32+
reconnectTimer = setTimeout(() => {
33+
if (backoffIdx === 0) {
34+
// deno-lint-ignore no-console
35+
console.log(
36+
`%c Fresh %c Connection closed. Trying to reconnect...`,
37+
"background-color: #86efac; color: black",
38+
"color: inherit",
39+
);
40+
}
41+
backoffIdx++;
42+
43+
try {
44+
connect();
45+
clearTimeout(reconnectTimer);
46+
} catch (_err) {
47+
reconnect();
48+
}
49+
}, backoff[Math.min(backoffIdx, backoff.length - 1)]);
50+
}
51+
52+
function onOpenWs() {
53+
backoffIdx = 0;
54+
}
55+
56+
function onCloseWs() {
57+
disconnect();
58+
reconnect();
59+
}
60+
61+
function connect() {
62+
const url = new URL("/_frsh/alive", location.origin.replace("http", "ws"));
63+
ws = new WebSocket(
64+
url,
65+
);
66+
67+
ws.addEventListener("open", onOpenWs);
68+
ws.addEventListener("close", onCloseWs);
69+
ws.addEventListener("message", handleMessage);
70+
ws.addEventListener("error", handleError);
71+
}
72+
73+
function disconnect() {
74+
ws.removeEventListener("open", onOpenWs);
75+
ws.removeEventListener("close", onCloseWs);
76+
ws.removeEventListener("message", handleMessage);
77+
ws.removeEventListener("error", handleError);
78+
ws.close();
79+
}
80+
81+
function handleMessage(e: MessageEvent) {
82+
const data = JSON.parse(e.data);
83+
switch (data.type) {
84+
case "initial-state": {
85+
if (revision === 0) {
86+
// deno-lint-ignore no-console
87+
console.log(
88+
`%c Fresh %c Connected to development server.`,
89+
"background-color: #86efac; color: black",
90+
"color: inherit",
91+
);
92+
}
93+
94+
if (revision === 0) {
95+
revision = data.revision;
96+
} else if (revision < data.revision) {
97+
disconnect();
98+
// Needs reload
99+
location.reload();
100+
}
101+
}
102+
}
103+
}
104+
105+
function handleError(e: Event) {
106+
// TODO
107+
// deno-lint-ignore no-explicit-any
108+
if (e && (e as any).code === "ECONNREFUSED") {
109+
setTimeout(connect, 1000);
110+
}
111+
}
112+
113+
if (IS_BROWSER) {
114+
connect();
115+
116+
addEventListener("message", (ev) => {
117+
if (ev.origin !== location.origin) return;
118+
if (typeof ev.data !== "string" || ev.data !== "close-error-overlay") {
119+
return;
120+
}
121+
122+
document.querySelector("#fresh-error-overlay")?.remove();
123+
});
124+
125+
// Disconnect when the tab becomes inactive and re-connect when it
126+
// becomes active again
127+
addEventListener("visibilitychange", () => {
128+
if (document.hidden) {
129+
disconnect();
130+
} else {
131+
connect();
132+
}
133+
});
134+
}

0 commit comments

Comments
 (0)