Skip to content

Commit 8f03760

Browse files
lxsmnsycclaudebrenelz
authored
feat(1.x): add an earlyHints option for 103 Early Hints (#2351)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
1 parent 95ca993 commit 8f03760

9 files changed

Lines changed: 161 additions & 1 deletion

File tree

‎.changeset/early-hints.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": patch
3+
---
4+
5+
Add an `earlyHints` option that sends the page's assets as `103 Early Hints`, so the browser can start downloading them while the server is still rendering. It covers the app's entry assets and the assets of the routes being rendered. On runtimes other than Node, the same hints are added as `Link` headers on the response. Off by default, and only applies to production builds.

‎packages/start/config/index.d.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ type SolidStartInlineConfig = {
2323
routeDir?: string;
2424
middleware?: string;
2525
devOverlay?: boolean;
26+
/**
27+
* Send the page's assets as `103 Early Hints` before the page is rendered, so
28+
* the browser can start downloading them while the server is still working.
29+
* This covers the app's entry assets and the assets of the routes being
30+
* rendered. On runtimes other than Node, the hints are added as `Link`
31+
* headers on the response instead. Only applies to production builds.
32+
*
33+
* Off by default, because some proxies and clients mishandle `1xx` responses.
34+
*
35+
* @default false
36+
*/
37+
earlyHints?: boolean;
2638
/**
2739
* Reload the page once when a route's code fails to load during client-side
2840
* navigation, such as when a deploy replaced it while the page was open.

‎packages/start/config/index.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export function defineConfig(baseConfig = {}) {
8181
routeDir: "./routes",
8282
ssr: true,
8383
devOverlay: true,
84+
earlyHints: false,
8485
reloadOnRouteChunkError: true,
8586
experimental: {
8687
islands: false
@@ -176,6 +177,7 @@ export function defineConfig(baseConfig = {}) {
176177
"import.meta.env.SSR": JSON.stringify(true),
177178
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
178179
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
180+
"import.meta.env.START_EARLY_HINTS": JSON.stringify(start.earlyHints),
179181
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
180182
start.reloadOnRouteChunkError
181183
),
@@ -246,6 +248,7 @@ export function defineConfig(baseConfig = {}) {
246248
"import.meta.env.SSR": JSON.stringify(false),
247249
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
248250
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
251+
"import.meta.env.START_EARLY_HINTS": JSON.stringify(start.earlyHints),
249252
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
250253
start.reloadOnRouteChunkError
251254
),
@@ -310,6 +313,7 @@ export function defineConfig(baseConfig = {}) {
310313
"import.meta.env.SSR": JSON.stringify(true),
311314
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
312315
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
316+
"import.meta.env.START_EARLY_HINTS": JSON.stringify(start.earlyHints),
313317
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
314318
start.reloadOnRouteChunkError
315319
),

‎packages/start/env.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface SolidStartMetaEnv {
2222
START_SSR: boolean;
2323
START_ISLANDS: boolean;
2424
START_DEV_OVERLAY: boolean;
25+
START_EARLY_HINTS: boolean;
2526
START_RELOAD_ON_ROUTE_CHUNK_ERROR: boolean;
2627
SERVER_BASE_URL: string;
2728
}

‎packages/start/src/router/lazyRoute.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/// <reference types="vinxi/types/client" />
22
import { createComponent, lazy, onCleanup, type Component, type JSX } from "solid-js";
3-
import { isServer } from "solid-js/web";
3+
import { getRequestEvent, isServer } from "solid-js/web";
44
import { appendStyles, cleanupStyles, preloadStyles, updateStyles } from "vinxi/css";
55

6+
import { sendEarlyHints } from "../server/early-hints";
67
import { renderAsset } from "../server/renderAsset";
78
import { Asset } from "../server/types";
89
import { withRouteChunkReload } from "../shared/route-chunk-reload";
@@ -60,6 +61,12 @@ export default function lazyRoute<T extends Record<string, any>>(component: any,
6061
preloadStyles(styles);
6162
}
6263
const Comp: Component<T> = props => {
64+
// Runs on every render, unlike the code above, which runs once per
65+
// route because lazy() caches it.
66+
if (isServer) {
67+
const event = getRequestEvent();
68+
if (event) sendEarlyHints(event, styles);
69+
}
6370
return [...styles.map((asset: Asset) => renderAsset(asset)), createComponent(Component, props)];
6471
};
6572
return { default: Comp };

‎packages/start/src/server/StartServer.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
useAssets
1212
} from "solid-js/web";
1313
import { ErrorBoundary, TopErrorBoundary } from "../shared/ErrorBoundary";
14+
import { sendEarlyHints } from "./early-hints";
1415
import { renderAsset } from "./renderAsset";
1516
import type { Asset, DocumentComponentProps, PageEvent } from "./types";
1617

@@ -68,6 +69,7 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
6869
!context.assets.find((a: Asset) => a.attrs.key === asset.attrs.key)
6970
)
7071
);
72+
sendEarlyHints(context, assets);
7173
});
7274

7375
useAssets(() => (assets.length ? assets.map(m => renderAsset(m)) : undefined));
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { sendEarlyHints, toLinkValues } from "./early-hints";
3+
import type { Asset, FetchEvent } from "./types";
4+
5+
const assets: Asset[] = [
6+
{ tag: "link", attrs: { href: "/_build/assets/client.css", rel: "stylesheet" } },
7+
{ tag: "link", attrs: { href: "/_build/assets/client.js", rel: "modulepreload" } },
8+
{ tag: "link", attrs: { href: "/_build/assets/漢字-abc.js", rel: "modulepreload" } },
9+
{ tag: "style", attrs: {}, children: "body {}" },
10+
{ tag: "link", attrs: { href: "/favicon.ico", rel: "icon" } }
11+
];
12+
13+
function createEvent(res: Record<string, unknown>) {
14+
return { nativeEvent: { node: { res } } } as unknown as FetchEvent;
15+
}
16+
17+
describe("toLinkValues", () => {
18+
it("preloads stylesheets and modulepreloads scripts, like the page does", () => {
19+
expect(toLinkValues(assets)).toEqual([
20+
"</_build/assets/client.css>; rel=preload; as=style",
21+
"</_build/assets/client.js>; rel=modulepreload",
22+
"</_build/assets/%E6%BC%A2%E5%AD%97-abc.js>; rel=modulepreload"
23+
]);
24+
});
25+
});
26+
27+
describe("sendEarlyHints", () => {
28+
beforeEach(() => {
29+
vi.stubEnv("PROD", true);
30+
// A build inlines a boolean here. Vitest stores env values as strings, so a
31+
// test can only set it (truthy) or leave it unset (falsy).
32+
import.meta.env.START_EARLY_HINTS = true;
33+
});
34+
35+
afterEach(() => {
36+
vi.unstubAllEnvs();
37+
delete (import.meta.env as Partial<ImportMetaEnv>).START_EARLY_HINTS;
38+
});
39+
40+
it("writes a 103 response on Node", () => {
41+
const res = { socket: {}, headersSent: false, writeEarlyHints: vi.fn(), appendHeader: vi.fn() };
42+
sendEarlyHints(createEvent(res), assets);
43+
44+
expect(res.writeEarlyHints).toHaveBeenCalledWith({ link: toLinkValues(assets) });
45+
expect(res.appendHeader).not.toHaveBeenCalled();
46+
});
47+
48+
it("adds Link headers instead when the response has no socket", () => {
49+
const res = {
50+
socket: null,
51+
headersSent: false,
52+
writeEarlyHints: vi.fn(),
53+
appendHeader: vi.fn()
54+
};
55+
sendEarlyHints(createEvent(res), assets);
56+
57+
expect(res.writeEarlyHints).not.toHaveBeenCalled();
58+
expect(res.appendHeader.mock.calls).toEqual(toLinkValues(assets).map(link => ["link", link]));
59+
});
60+
61+
it("does nothing when the earlyHints option is off", () => {
62+
delete (import.meta.env as Partial<ImportMetaEnv>).START_EARLY_HINTS;
63+
const res = { socket: {}, headersSent: false, writeEarlyHints: vi.fn(), appendHeader: vi.fn() };
64+
sendEarlyHints(createEvent(res), assets);
65+
expect(res.writeEarlyHints).not.toHaveBeenCalled();
66+
});
67+
68+
it("does nothing outside production", () => {
69+
vi.stubEnv("PROD", false);
70+
const res = { socket: {}, headersSent: false, writeEarlyHints: vi.fn(), appendHeader: vi.fn() };
71+
sendEarlyHints(createEvent(res), assets);
72+
expect(res.writeEarlyHints).not.toHaveBeenCalled();
73+
});
74+
75+
it("does nothing once the response headers are sent", () => {
76+
const res = { socket: {}, headersSent: true, writeEarlyHints: vi.fn(), appendHeader: vi.fn() };
77+
sendEarlyHints(createEvent(res), assets);
78+
expect(res.writeEarlyHints).not.toHaveBeenCalled();
79+
expect(res.appendHeader).not.toHaveBeenCalled();
80+
});
81+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Asset, FetchEvent } from "./types";
2+
3+
/**
4+
* Turns the page's `<link>` assets into `Link` header values: stylesheets as
5+
* `preload` and scripts as `modulepreload`, the same way the page links them.
6+
*/
7+
export function toLinkValues(assets: Asset[]): string[] {
8+
const links: string[] = [];
9+
for (const asset of assets) {
10+
if (asset.tag !== "link") continue;
11+
const { href, rel } = asset.attrs as { href?: unknown; rel?: unknown };
12+
if (typeof href !== "string") continue;
13+
// Header values must be ASCII, and a chunk name can contain any character
14+
// a route file name can.
15+
const target = `<${encodeURI(href)}>`;
16+
if (rel === "stylesheet") links.push(`${target}; rel=preload; as=style`);
17+
else if (rel === "modulepreload") links.push(`${target}; rel=modulepreload`);
18+
}
19+
return links;
20+
}
21+
22+
/**
23+
* Sends the assets as `103 Early Hints` when the server runs on Node, or adds
24+
* them as `Link` headers on the response otherwise. Only in production, and
25+
* only when the `earlyHints` option is on.
26+
*
27+
* Hints only help before the response headers are sent, so this does nothing
28+
* once they are.
29+
*
30+
* @see https://github.com/solidjs/solid-start/issues/1935
31+
*/
32+
export function sendEarlyHints(event: FetchEvent, assets: Asset[]): void {
33+
if (!import.meta.env.PROD || !import.meta.env.START_EARLY_HINTS) return;
34+
const res = event.nativeEvent.node?.res;
35+
if (!res || res.headersSent) return;
36+
const links = toLinkValues(assets);
37+
if (links.length === 0) return;
38+
// Only a real Node response has a socket. Other runtimes run on a mock
39+
// response whose writeEarlyHints does nothing.
40+
if (res.socket && typeof res.writeEarlyHints === "function") {
41+
res.writeEarlyHints({ link: links });
42+
} else {
43+
for (const link of links) res.appendHeader("link", link);
44+
}
45+
}

‎packages/start/src/server/pageEvent.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getCookie, setCookie } from "vinxi/http";
22
import { createRoutes } from "../router/FileRoutes";
33
import { FetchEvent, PageEvent } from "./types";
4+
import { sendEarlyHints } from "./early-hints";
45

56
function initFromFlash(ctx: FetchEvent) {
67
const flash = getCookie(ctx.nativeEvent, "flash");
@@ -54,5 +55,7 @@ export async function createPageEvent(ctx: FetchEvent) {
5455
$islands: new Set<string>()
5556
});
5657

58+
sendEarlyHints(ctx, pageEvent.assets);
59+
5760
return pageEvent;
5861
}

0 commit comments

Comments
 (0)