Skip to content
Merged
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.
12 changes: 12 additions & 0 deletions packages/start/config/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
* Reload the page once when a route's code fails to load during client-side
* navigation, such as when a deploy replaced it while the page was open.
Expand Down
4 changes: 4 additions & 0 deletions packages/start/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function defineConfig(baseConfig = {}) {
routeDir: "./routes",
ssr: true,
devOverlay: true,
earlyHints: false,
reloadOnRouteChunkError: true,
experimental: {
islands: false
Expand Down Expand Up @@ -176,6 +177,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.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
start.reloadOnRouteChunkError
),
Expand Down Expand Up @@ -246,6 +248,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.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
start.reloadOnRouteChunkError
),
Expand Down Expand Up @@ -310,6 +313,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.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
start.reloadOnRouteChunkError
),
Expand Down
1 change: 1 addition & 0 deletions packages/start/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface SolidStartMetaEnv {
START_SSR: boolean;
START_ISLANDS: boolean;
START_DEV_OVERLAY: boolean;
START_EARLY_HINTS: boolean;
START_RELOAD_ON_ROUTE_CHUNK_ERROR: boolean;
SERVER_BASE_URL: string;
}
9 changes: 8 additions & 1 deletion packages/start/src/router/lazyRoute.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/// <reference types="vinxi/types/client" />
import { createComponent, lazy, onCleanup, type Component, type JSX } from "solid-js";
import { isServer } from "solid-js/web";
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";
import { withRouteChunkReload } from "../shared/route-chunk-reload";
Expand Down Expand Up @@ -60,6 +61,12 @@ export default function lazyRoute<T extends Record<string, any>>(component: any,
preloadStyles(styles);
}
const Comp: Component<T> = 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 };
Expand Down
2 changes: 2 additions & 0 deletions packages/start/src/server/StartServer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -68,6 +69,7 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
!context.assets.find((a: Asset) => a.attrs.key === asset.attrs.key)
)
);
sendEarlyHints(context, assets);
});

useAssets(() => (assets.length ? assets.map(m => renderAsset(m)) : undefined));
Expand Down
81 changes: 81 additions & 0 deletions packages/start/src/server/early-hints.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
return { nativeEvent: { node: { res } } } as unknown as FetchEvent;
}

describe("toLinkValues", () => {
it("preloads stylesheets and modulepreloads scripts, like the page does", () => {
expect(toLinkValues(assets)).toEqual([
"</_build/assets/client.css>; rel=preload; as=style",
"</_build/assets/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;
});

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<ImportMetaEnv>).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();
});
});
45 changes: 45 additions & 0 deletions packages/start/src/server/early-hints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Asset, FetchEvent } from "./types";

/**
* 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 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);
}
}
3 changes: 3 additions & 0 deletions packages/start/src/server/pageEvent.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -54,5 +55,7 @@ export async function createPageEvent(ctx: FetchEvent) {
$islands: new Set<string>()
});

sendEarlyHints(ctx, pageEvent.assets);

return pageEvent;
}
Loading