From 5c04a8759321fa0277fe0dabda1cc3929f5914fc Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 14 Oct 2026 12:49:31 +0800 Subject: [PATCH] feat(1.x): add an earlyHints option for 103 Early Hints Send the page's assets as 103 Early Hints before the response. On Node this uses the response's own writeEarlyHints. On other runtimes, which run on a mock response with no socket, the values are added as Link headers instead. - Entry assets are sent from createPageEvent. - Route scripts are sent from StartServer, where it collects the matched routes' modulepreload assets. - Route stylesheets are sent from lazyRoute's component, which runs on every render. - Nothing is sent once the response headers are out. Off by default, because some proxies and clients mishandle 1xx responses. Production builds only. Closes #1935. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/early-hints.md | 5 ++ packages/start/config/index.d.ts | 12 +++ packages/start/config/index.js | 4 + packages/start/env.d.ts | 1 + packages/start/src/router/lazyRoute.ts | 8 ++ packages/start/src/server/StartServer.tsx | 2 + packages/start/src/server/early-hints.spec.ts | 81 +++++++++++++++++++ packages/start/src/server/early-hints.ts | 45 +++++++++++ packages/start/src/server/pageEvent.ts | 3 + 9 files changed, 161 insertions(+) create mode 100644 .changeset/early-hints.md create mode 100644 packages/start/src/server/early-hints.spec.ts create mode 100644 packages/start/src/server/early-hints.ts diff --git a/.changeset/early-hints.md b/.changeset/early-hints.md new file mode 100644 index 000000000..ff6b1cde7 --- /dev/null +++ b/.changeset/early-hints.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +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. diff --git a/packages/start/config/index.d.ts b/packages/start/config/index.d.ts index bb1613f64..11b0d41f4 100644 --- a/packages/start/config/index.d.ts +++ b/packages/start/config/index.d.ts @@ -23,6 +23,18 @@ type SolidStartInlineConfig = { routeDir?: string; middleware?: string; devOverlay?: boolean; + /** + * Send the page's assets as `103 Early Hints` before the page is rendered, so + * the browser can start downloading them while the server is still working. + * This covers the app's entry assets and the assets of the routes being + * rendered. On runtimes other than Node, the hints are added as `Link` + * headers on the response instead. Only applies to production builds. + * + * Off by default, because some proxies and clients mishandle `1xx` responses. + * + * @default false + */ + earlyHints?: boolean; experimental?: { islands?: boolean; }; diff --git a/packages/start/config/index.js b/packages/start/config/index.js index 53d1946af..184a46503 100644 --- a/packages/start/config/index.js +++ b/packages/start/config/index.js @@ -81,6 +81,7 @@ export function defineConfig(baseConfig = {}) { routeDir: "./routes", ssr: true, devOverlay: true, + earlyHints: false, experimental: { islands: false }, @@ -175,6 +176,7 @@ export function defineConfig(baseConfig = {}) { "import.meta.env.SSR": JSON.stringify(true), "import.meta.env.START_SSR": JSON.stringify(start.ssr), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), + "import.meta.env.START_EARLY_HINTS": JSON.stringify(start.earlyHints), "import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode), ...userConfig.define } @@ -242,6 +244,7 @@ export function defineConfig(baseConfig = {}) { "import.meta.env.SSR": JSON.stringify(false), "import.meta.env.START_SSR": JSON.stringify(start.ssr), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), + "import.meta.env.START_EARLY_HINTS": JSON.stringify(start.earlyHints), "import.meta.env.SERVER_BASE_URL": JSON.stringify(server?.baseURL ?? ""), "import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode), ...userConfig.define @@ -303,6 +306,7 @@ export function defineConfig(baseConfig = {}) { "import.meta.env.SSR": JSON.stringify(true), "import.meta.env.START_SSR": JSON.stringify(start.ssr), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), + "import.meta.env.START_EARLY_HINTS": JSON.stringify(start.earlyHints), "import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode), ...userConfig.define } diff --git a/packages/start/env.d.ts b/packages/start/env.d.ts index 14f796189..3b2cce106 100644 --- a/packages/start/env.d.ts +++ b/packages/start/env.d.ts @@ -22,5 +22,6 @@ interface SolidStartMetaEnv { START_SSR: boolean; START_ISLANDS: boolean; START_DEV_OVERLAY: boolean; + START_EARLY_HINTS: boolean; SERVER_BASE_URL: string; } diff --git a/packages/start/src/router/lazyRoute.ts b/packages/start/src/router/lazyRoute.ts index ea39432d7..4f6618769 100644 --- a/packages/start/src/router/lazyRoute.ts +++ b/packages/start/src/router/lazyRoute.ts @@ -1,7 +1,9 @@ /// import { createComponent, lazy, onCleanup, type Component, type JSX } from "solid-js"; +import { getRequestEvent, isServer } from "solid-js/web"; import { appendStyles, cleanupStyles, preloadStyles, updateStyles } from "vinxi/css"; +import { sendEarlyHints } from "../server/early-hints"; import { renderAsset } from "../server/renderAsset"; import { Asset } from "../server/types"; @@ -52,6 +54,12 @@ export default function lazyRoute>(component: any, preloadStyles(styles); } const Comp: Component = props => { + // Runs on every render, unlike the code above, which runs once per + // route because lazy() caches it. + if (isServer) { + const event = getRequestEvent(); + if (event) sendEarlyHints(event, styles); + } return [...styles.map((asset: Asset) => renderAsset(asset)), createComponent(Component, props)]; }; return { default: Comp }; diff --git a/packages/start/src/server/StartServer.tsx b/packages/start/src/server/StartServer.tsx index cd3849fad..4516cfcbf 100644 --- a/packages/start/src/server/StartServer.tsx +++ b/packages/start/src/server/StartServer.tsx @@ -11,6 +11,7 @@ import { useAssets } from "solid-js/web"; import { ErrorBoundary, TopErrorBoundary } from "../shared/ErrorBoundary"; +import { sendEarlyHints } from "./early-hints"; import { renderAsset } from "./renderAsset"; import type { Asset, DocumentComponentProps, PageEvent } from "./types"; @@ -68,6 +69,7 @@ export function StartServer(props: { document: Component !context.assets.find((a: Asset) => a.attrs.key === asset.attrs.key) ) ); + sendEarlyHints(context, assets); }); useAssets(() => (assets.length ? assets.map(m => renderAsset(m)) : undefined)); diff --git a/packages/start/src/server/early-hints.spec.ts b/packages/start/src/server/early-hints.spec.ts new file mode 100644 index 000000000..acb8763ed --- /dev/null +++ b/packages/start/src/server/early-hints.spec.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sendEarlyHints, toLinkValues } from "./early-hints"; +import type { Asset, FetchEvent } from "./types"; + +const assets: Asset[] = [ + { tag: "link", attrs: { href: "/_build/assets/client.css", rel: "stylesheet" } }, + { tag: "link", attrs: { href: "/_build/assets/client.js", rel: "modulepreload" } }, + { tag: "link", attrs: { href: "/_build/assets/漢字-abc.js", rel: "modulepreload" } }, + { tag: "style", attrs: {}, children: "body {}" }, + { tag: "link", attrs: { href: "/favicon.ico", rel: "icon" } } +]; + +function createEvent(res: Record) { + return { nativeEvent: { node: { res } } } as unknown as FetchEvent; +} + +describe("toLinkValues", () => { + it("preloads stylesheets and modulepreloads scripts, like the page does", () => { + expect(toLinkValues(assets)).toEqual([ + "; rel=preload; as=style", + "; rel=modulepreload", + "; rel=modulepreload" + ]); + }); +}); + +describe("sendEarlyHints", () => { + beforeEach(() => { + vi.stubEnv("PROD", true); + // A build inlines a boolean here. Vitest stores env values as strings, so a + // test can only set it (truthy) or leave it unset (falsy). + import.meta.env.START_EARLY_HINTS = true; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + delete (import.meta.env as Partial).START_EARLY_HINTS; + }); + + it("writes a 103 response on Node", () => { + const res = { socket: {}, headersSent: false, writeEarlyHints: vi.fn(), appendHeader: vi.fn() }; + sendEarlyHints(createEvent(res), assets); + + expect(res.writeEarlyHints).toHaveBeenCalledWith({ link: toLinkValues(assets) }); + expect(res.appendHeader).not.toHaveBeenCalled(); + }); + + it("adds Link headers instead when the response has no socket", () => { + const res = { + socket: null, + headersSent: false, + writeEarlyHints: vi.fn(), + appendHeader: vi.fn() + }; + sendEarlyHints(createEvent(res), assets); + + expect(res.writeEarlyHints).not.toHaveBeenCalled(); + expect(res.appendHeader.mock.calls).toEqual(toLinkValues(assets).map(link => ["link", link])); + }); + + it("does nothing when the earlyHints option is off", () => { + delete (import.meta.env as Partial).START_EARLY_HINTS; + const res = { socket: {}, headersSent: false, writeEarlyHints: vi.fn(), appendHeader: vi.fn() }; + sendEarlyHints(createEvent(res), assets); + expect(res.writeEarlyHints).not.toHaveBeenCalled(); + }); + + it("does nothing outside production", () => { + vi.stubEnv("PROD", false); + const res = { socket: {}, headersSent: false, writeEarlyHints: vi.fn(), appendHeader: vi.fn() }; + sendEarlyHints(createEvent(res), assets); + expect(res.writeEarlyHints).not.toHaveBeenCalled(); + }); + + it("does nothing once the response headers are sent", () => { + const res = { socket: {}, headersSent: true, writeEarlyHints: vi.fn(), appendHeader: vi.fn() }; + sendEarlyHints(createEvent(res), assets); + expect(res.writeEarlyHints).not.toHaveBeenCalled(); + expect(res.appendHeader).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/start/src/server/early-hints.ts b/packages/start/src/server/early-hints.ts new file mode 100644 index 000000000..260d6f76f --- /dev/null +++ b/packages/start/src/server/early-hints.ts @@ -0,0 +1,45 @@ +import type { Asset, FetchEvent } from "./types"; + +/** + * Turns the page's `` assets into `Link` header values: stylesheets as + * `preload` and scripts as `modulepreload`, the same way the page links them. + */ +export function toLinkValues(assets: Asset[]): string[] { + const links: string[] = []; + for (const asset of assets) { + if (asset.tag !== "link") continue; + const { href, rel } = asset.attrs as { href?: unknown; rel?: unknown }; + if (typeof href !== "string") continue; + // Header values must be ASCII, and a chunk name can contain any character + // a route file name can. + const target = `<${encodeURI(href)}>`; + if (rel === "stylesheet") links.push(`${target}; rel=preload; as=style`); + else if (rel === "modulepreload") links.push(`${target}; rel=modulepreload`); + } + return links; +} + +/** + * Sends the assets as `103 Early Hints` when the server runs on Node, or adds + * them as `Link` headers on the response otherwise. Only in production, and + * only when the `earlyHints` option is on. + * + * Hints only help before the response headers are sent, so this does nothing + * once they are. + * + * @see https://github.com/solidjs/solid-start/issues/1935 + */ +export function sendEarlyHints(event: FetchEvent, assets: Asset[]): void { + if (!import.meta.env.PROD || !import.meta.env.START_EARLY_HINTS) return; + const res = event.nativeEvent.node?.res; + if (!res || res.headersSent) return; + const links = toLinkValues(assets); + if (links.length === 0) return; + // Only a real Node response has a socket. Other runtimes run on a mock + // response whose writeEarlyHints does nothing. + if (res.socket && typeof res.writeEarlyHints === "function") { + res.writeEarlyHints({ link: links }); + } else { + for (const link of links) res.appendHeader("link", link); + } +} diff --git a/packages/start/src/server/pageEvent.ts b/packages/start/src/server/pageEvent.ts index c105673b0..963edadd7 100644 --- a/packages/start/src/server/pageEvent.ts +++ b/packages/start/src/server/pageEvent.ts @@ -1,6 +1,7 @@ import { getCookie, setCookie } from "vinxi/http"; import { createRoutes } from "../router/FileRoutes"; import { FetchEvent, PageEvent } from "./types"; +import { sendEarlyHints } from "./early-hints"; function initFromFlash(ctx: FetchEvent) { const flash = getCookie(ctx.nativeEvent, "flash"); @@ -54,5 +55,7 @@ export async function createPageEvent(ctx: FetchEvent) { $islands: new Set() }); + sendEarlyHints(ctx, pageEvent.assets); + return pageEvent; }