From 1cf2ab2686e4c62cb170ee4a03e7720d84c89054 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 14 Oct 2026 12:45:16 +0800 Subject: [PATCH] feat(server): add an earlyHints option for 103 Early Hints Send the page's assets as 103 Early Hints before the response, using h3's writeEarlyHints. On Node that writes a real 103 response; on other runtimes h3 adds the same values as Link headers on the response. - Entry assets (client entry, app entry, merged CSS) are sent from createPageEvent, before rendering starts. - Route assets are sent from the server lazy() wrapper when a route renders. That code runs on every render, unlike the module-level part that lazy() caches. - Nothing is sent once the response headers are out. - Stylesheets become rel=preload; as=style and scripts rel=modulepreload, matching the page's own tags. URLs are encodeURI'd, since header values must be ASCII. Off by default, like Nuxt's experimental.writeEarlyHints, 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/src/config/index.ts | 15 ++++ packages/start/src/env.d.ts | 1 + packages/start/src/server/early-hints.spec.ts | 76 +++++++++++++++++++ packages/start/src/server/early-hints.ts | 40 ++++++++++ packages/start/src/server/handler.ts | 2 + packages/start/src/shared/lazy.ts | 8 +- 7 files changed, 145 insertions(+), 2 deletions(-) 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/src/config/index.ts b/packages/start/src/config/index.ts index 658391c81..92a010284 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -67,6 +67,19 @@ export interface SolidStartOptions { */ 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 features. */ @@ -232,6 +245,7 @@ export function solidStart(options?: SolidStartOptions): Array { routeDir: "./routes", ssr: true, devOverlay: true, + earlyHints: false, experimental: { islands: false, }, @@ -374,6 +388,7 @@ export function solidStart(options?: SolidStartOptions): Array { "import.meta.env.START_CLIENT_ENTRY": JSON.stringify(handlers.client), "import.meta.env.START_CLIENT_ENTRY_URL": JSON.stringify(clientEntryUrl), "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(resolveServerBaseUrl(config)), "import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || "json"), }, diff --git a/packages/start/src/env.d.ts b/packages/start/src/env.d.ts index da45f74d6..eb37240f5 100644 --- a/packages/start/src/env.d.ts +++ b/packages/start/src/env.d.ts @@ -13,5 +13,6 @@ interface SolidStartMetaEnv { START_CLIENT_ENTRY: string; START_CLIENT_ENTRY_URL: string; START_ISLANDS: boolean; + START_EARLY_HINTS: boolean; // START_DEV_OVERLAY: boolean; } 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..7d26d4d49 --- /dev/null +++ b/packages/start/src/server/early-hints.spec.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Asset } from "./assets/render.tsx"; +import type { FetchEvent } from "./types.ts"; + +const writeEarlyHints = vi.fn(); +vi.mock("h3", () => ({ writeEarlyHints })); + +const { sendEarlyHints, toLinkValues } = await import("./early-hints.ts"); + +const assets: Asset[] = [ + { tag: "link", attrs: { href: "/_build/assets/entry-client.css", rel: "stylesheet" } }, + { tag: "link", attrs: { href: "/_build/assets/entry-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(headersSent = false) { + return { nativeEvent: { runtime: { node: { res: { headersSent } } } } } 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; + writeEarlyHints.mockReset(); + }); + + it("sends the assets as early hints", () => { + const event = createEvent(); + sendEarlyHints(event, assets); + + expect(writeEarlyHints).toHaveBeenCalledWith(event.nativeEvent, { + link: toLinkValues(assets), + }); + }); + + it("does nothing when the earlyHints option is off", () => { + delete (import.meta.env as Partial).START_EARLY_HINTS; + sendEarlyHints(createEvent(), assets); + expect(writeEarlyHints).not.toHaveBeenCalled(); + }); + + it("does nothing outside production", () => { + vi.stubEnv("PROD", false); + sendEarlyHints(createEvent(), assets); + expect(writeEarlyHints).not.toHaveBeenCalled(); + }); + + it("does nothing once the response headers are sent", () => { + sendEarlyHints(createEvent(true), assets); + expect(writeEarlyHints).not.toHaveBeenCalled(); + }); + + it("does nothing when there is nothing to hint", () => { + sendEarlyHints(createEvent(), [{ tag: "style", attrs: {}, children: "body {}" }]); + expect(writeEarlyHints).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..1ca71c787 --- /dev/null +++ b/packages/start/src/server/early-hints.ts @@ -0,0 +1,40 @@ +import { writeEarlyHints } from "h3"; +import type { Asset } from "./assets/render.tsx"; +import type { FetchEvent } from "./types.ts"; + +/** + * 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; + 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; + if (event.nativeEvent.runtime?.node?.res?.headersSent) return; + const links = toLinkValues(assets); + if (links.length === 0) return; + void writeEarlyHints(event.nativeEvent, { link: links }); +} diff --git a/packages/start/src/server/handler.ts b/packages/start/src/server/handler.ts index 550785857..9737709d7 100644 --- a/packages/start/src/server/handler.ts +++ b/packages/start/src/server/handler.ts @@ -6,6 +6,7 @@ import { getRequestEvent, renderToStream, renderToString } from "solid-js/web"; import { createRoutes } from "../router.tsx"; import { decorateHandler, decorateMiddleware } from "./fetchEvent.ts"; +import { sendEarlyHints } from "./early-hints.ts"; import { getSsrManifest } from "./manifest/ssr-manifest.ts"; import { matchAPIRoute } from "./routes.ts"; import { handleServerFunction } from "../fns/handler.ts"; @@ -150,6 +151,7 @@ export async function createPageEvent(ctx: FetchEvent) { // ) // : []) ]; + sendEarlyHints(ctx, assets); const pageEvent: PageEvent = Object.assign(ctx, { assets, router: { diff --git a/packages/start/src/shared/lazy.ts b/packages/start/src/shared/lazy.ts index 4904e57ba..0c6c5091d 100644 --- a/packages/start/src/shared/lazy.ts +++ b/packages/start/src/shared/lazy.ts @@ -2,6 +2,7 @@ import { Component, lazy as solidLazy } from "solid-js"; import { getRequestEvent, isServer } from "solid-js/web"; import { getManifest } from "solid-start:get-manifest"; import { useAssets } from "../server/assets/index.ts"; +import { sendEarlyHints } from "../server/early-hints.ts"; import { Asset, PageEvent } from "../server/types.ts"; const assetsById: Record = {}; @@ -34,8 +35,11 @@ const withAssets = function Promise<{ default: Component } return { default: (props: any) => { - const { nonce } = getRequestEvent() as PageEvent; - useAssets(assets, nonce); + const event = getRequestEvent() as PageEvent; + // Runs on every render, unlike the code above, which runs once per + // module because lazy() caches it. + sendEarlyHints(event, assets); + useAssets(assets, event.nonce); return mod.default(props); },