Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/early-hints.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -232,6 +245,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
routeDir: "./routes",
ssr: true,
devOverlay: true,
earlyHints: false,
experimental: {
islands: false,
},
Expand Down Expand Up @@ -374,6 +388,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
"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"),
},
Expand Down
1 change: 1 addition & 0 deletions packages/start/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
76 changes: 76 additions & 0 deletions packages/start/src/server/early-hints.spec.ts
Original file line number Diff line number Diff line change
@@ -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([
"</_build/assets/entry-client.css>; rel=preload; as=style",
"</_build/assets/entry-client.js>; rel=modulepreload",
"</_build/assets/%E6%BC%A2%E5%AD%97-abc.js>; 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<ImportMetaEnv>).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<ImportMetaEnv>).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();
});
});
40 changes: 40 additions & 0 deletions packages/start/src/server/early-hints.ts
Original file line number Diff line number Diff line change
@@ -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 `<link>` 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 });
}
2 changes: 2 additions & 0 deletions packages/start/src/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -150,6 +151,7 @@ export async function createPageEvent(ctx: FetchEvent) {
// )
// : [])
];
sendEarlyHints(ctx, assets);
const pageEvent: PageEvent = Object.assign(ctx, {
assets,
router: {
Expand Down
8 changes: 6 additions & 2 deletions packages/start/src/shared/lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Asset[]> = {};
Expand Down Expand Up @@ -34,8 +35,11 @@ const withAssets = function <T extends () => Promise<{ default: Component<any> }

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);
},
Expand Down
Loading