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/ssr-data-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": minor
---

Add `ssr: "data-only"`. The app renders on the client without hydration, as with `ssr: false`, but the server runs the requested page's route `preload`s and streams their query results with the HTML shell, so the client doesn't fetch that data again.
13 changes: 12 additions & 1 deletion packages/start/config/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@ type ViteCustomizableConfig = CustomizableConfig & {

type SolidStartInlineConfig = {
publicDir?: string;
ssr?: boolean;
/**
* Enable or disable server-side rendering.
*
* - `true` — SSR (default)
* - `false` — client-side rendering only (SPA mode)
* - `"data-only"` — SPA mode, but the server runs the route `preload`s for the
* requested page and sends their query results with the HTML shell. The
* client renders without hydrating and without fetching that data again.
*
* @default true
*/
ssr?: boolean | "data-only";
solid?: Options;
extensions?: string[];
server?: AppOptions["server"];
Expand Down
25 changes: 15 additions & 10 deletions packages/start/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ export function defineConfig(baseConfig = {}) {

const routeDir = join(start.appRoot, start.routeDir);
let server = start.server;
if (!start.ssr) {
// Data-only mode runs each page's preloads per request, so it can't use a
// prerendered shell.
if (start.ssr === false) {
server = { ...server, prerender: { routes: ["/"] } };
}
let entryExtension = ".tsx";
Expand Down Expand Up @@ -129,7 +131,7 @@ export function defineConfig(baseConfig = {}) {
},
handler: `${start.appRoot}/entry-server${entryExtension}`,
middleware: start.middleware,
routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: !start.ssr }),
routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: start.ssr !== true }),
extensions,
target: "server",
plugins: async () => {
Expand Down Expand Up @@ -158,7 +160,7 @@ export function defineConfig(baseConfig = {}) {
alias: {
"#start/app": join(process.cwd(), start.appRoot, `app${entryExtension}`),
"~": join(process.cwd(), start.appRoot),
...(!start.ssr
...(start.ssr !== true
? {
"@solidjs/start/server": "@solidjs/start/server/spa"
}
Expand All @@ -173,7 +175,8 @@ export function defineConfig(baseConfig = {}) {
),
"import.meta.env.START_ISLANDS": JSON.stringify(start.experimental.islands),
"import.meta.env.SSR": JSON.stringify(true),
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
"import.meta.env.START_SSR": JSON.stringify(start.ssr === true),
"import.meta.env.START_SSR_DATA": JSON.stringify(start.ssr === "data-only"),
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
"import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode),
...userConfig.define
Expand Down Expand Up @@ -214,7 +217,7 @@ export function defineConfig(baseConfig = {}) {
...plugins,
SolidStartServerFnsPlugin.client,
start.experimental.islands ? serverComponents.client() : null,
solid({ ...start.solid, ssr: start.ssr, extensions: extensions.map(ext => `.${ext}`) }),
solid({ ...start.solid, ssr: start.ssr === true, extensions: extensions.map(ext => `.${ext}`) }),
config("app-client", {
resolve: {
alias: {
Expand All @@ -225,7 +228,7 @@ export function defineConfig(baseConfig = {}) {
"@solidjs/start/client": "@solidjs/start/client/islands"
}
: {}),
...(!start.ssr
...(start.ssr !== true
? {
"@solidjs/start/client": "@solidjs/start/client/spa"
}
Expand All @@ -240,7 +243,8 @@ export function defineConfig(baseConfig = {}) {
),
"import.meta.env.START_ISLANDS": JSON.stringify(start.experimental.islands),
"import.meta.env.SSR": JSON.stringify(false),
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
"import.meta.env.START_SSR": JSON.stringify(start.ssr === true),
"import.meta.env.START_SSR_DATA": JSON.stringify(start.ssr === "data-only"),
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
"import.meta.env.SERVER_BASE_URL": JSON.stringify(server?.baseURL ?? ""),
"import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode),
Expand All @@ -259,7 +263,7 @@ export function defineConfig(baseConfig = {}) {
),
middleware: start.middleware,
target: "server",
routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: !start.ssr }),
routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: start.ssr !== true }),
plugins: async () => {
const userConfig =
typeof vite === "function" ? await vite({ router: "server-function" }) : { ...vite };
Expand Down Expand Up @@ -287,7 +291,7 @@ export function defineConfig(baseConfig = {}) {
alias: {
"#start/app": join(process.cwd(), start.appRoot, `app${entryExtension}`),
"~": join(process.cwd(), start.appRoot),
...(!start.ssr
...(start.ssr !== true
? {
"@solidjs/start/server": "@solidjs/start/server/spa"
}
Expand All @@ -301,7 +305,8 @@ export function defineConfig(baseConfig = {}) {
),
"import.meta.env.START_ISLANDS": JSON.stringify(start.experimental.islands),
"import.meta.env.SSR": JSON.stringify(true),
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
"import.meta.env.START_SSR": JSON.stringify(start.ssr === true),
"import.meta.env.START_SSR_DATA": JSON.stringify(start.ssr === "data-only"),
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
"import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode),
...userConfig.define
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 @@ -20,6 +20,7 @@ interface ImportMetaEnv extends Record<`VITE_${string}`, any>, SolidStartMetaEnv

interface SolidStartMetaEnv {
START_SSR: boolean;
START_SSR_DATA: boolean;
START_ISLANDS: boolean;
START_DEV_OVERLAY: boolean;
SERVER_BASE_URL: string;
Expand Down
8 changes: 7 additions & 1 deletion packages/start/src/client/spa/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
import type { JSX } from "solid-js";
import { render, type MountableElement } from "solid-js/web";
import "vinxi/client";
import { seedInitialData } from "./initial-data";

export function mount(fn: () => JSX.Element, el: MountableElement) {
render(fn, el);
const restore = import.meta.env.START_SSR_DATA ? seedInitialData() : undefined;
try {
render(fn, el);
} finally {
restore?.();
}
}

export { StartClient, StartClientTanstack } from "../StartClient";
29 changes: 29 additions & 0 deletions packages/start/src/client/spa/initial-data.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { sharedConfig } from "solid-js";
import { afterEach, describe, expect, it } from "vitest";

import { seedInitialData } from "./initial-data";

describe("seedInitialData", () => {
afterEach(() => {
delete (globalThis as any)._$HY;
});

it("does nothing without streamed data", () => {
expect(seedInitialData()).toBeUndefined();
expect(sharedConfig.has).toBeUndefined();
});

it("lets the first render read the streamed data, then puts sharedConfig back", () => {
const value = Promise.resolve({ id: 1 });
(globalThis as any)._$HY = { r: { 'user["1"]': value } };

const restore = seedInitialData()!;
expect(sharedConfig.has!('user["1"]')).toBe(true);
expect(sharedConfig.has!("other[]")).toBe(false);
expect(sharedConfig.load!('user["1"]')).toBe(value);

restore();
expect(sharedConfig.has).toBeUndefined();
expect(sharedConfig.load).toBeUndefined();
});
});
25 changes: 25 additions & 0 deletions packages/start/src/client/spa/initial-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { sharedConfig } from "solid-js";

/**
* With `ssr: "data-only"`, the HTML shell streams the query results of the
* requested page into `_$HY.r`, which is where hydration keeps serialized
* data. The router's `query` already reads that through `sharedConfig.has` and
* `sharedConfig.load` on a cache miss, but those are only set while
* hydrating. Set them for the first render so the queries take the streamed
* results instead of fetching them again.
*
* Returns a function that puts `sharedConfig` back once the first render has
* made its queries.
*/
export function seedInitialData(): (() => void) | undefined {
const records = (globalThis as { _$HY?: { r?: Record<string, unknown> } })._$HY?.r;
if (!records) return;

const { has, load } = sharedConfig;
sharedConfig.has = key => key in records;
sharedConfig.load = key => records[key];
return () => {
sharedConfig.has = has;
sharedConfig.load = load;
};
}
2 changes: 1 addition & 1 deletion packages/start/src/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function createBaseHandler(
// @ts-ignore
if (resolvedOptions.nonce) context.nonce = resolvedOptions.nonce;

if (mode === "sync" || !import.meta.env.START_SSR) {
if (mode === "sync" || (!import.meta.env.START_SSR && !import.meta.env.START_SSR_DATA)) {
const html = renderToString(() => {
(sharedConfig.context as any).event = context;
return fn(context);
Expand Down
27 changes: 25 additions & 2 deletions packages/start/src/server/spa/StartServer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// @refresh skip
// @ts-ignore
import type { Component } from "solid-js";
import { NoHydration, getRequestEvent, ssr } from "solid-js/web";
import { Hydration, HydrationScript, NoHydration, getRequestEvent, ssr } from "solid-js/web";
import { TopErrorBoundary } from "../../shared/ErrorBoundary";
import { renderAsset } from "../renderAsset";
import type { DocumentComponentProps, PageEvent } from "../types";
import { InitialData } from "./initial-data";

const docType = ssr("<!DOCTYPE html>");

Expand All @@ -21,10 +22,27 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
{docType as unknown as any}
<TopErrorBoundary>
<props.document
assets={<>{context.assets.map((m: any) => renderAsset(m))}</>}
assets={
<>
{import.meta.env.START_SSR_DATA && (
// Defines `_$HY.r` for the streamed data. Nothing hydrates, so
// it doesn't need to capture events for replay. The client
// types don't list `eventNames`, the server implementation
// takes it.
// @ts-ignore
<HydrationScript eventNames={[]} />
)}
{context.assets.map((m: any) => renderAsset(m))}
</>
}
scripts={
nonce ? (
<>
{import.meta.env.START_SSR_DATA && (
<Hydration>
<InitialData />
</Hydration>
)}
<script
nonce={nonce}
innerHTML={`window.manifest = ${JSON.stringify(context.manifest)}`}
Expand All @@ -40,6 +58,11 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
</>
) : (
<>
{import.meta.env.START_SSR_DATA && (
<Hydration>
<InitialData />
</Hydration>
)}
<script innerHTML={`window.manifest = ${JSON.stringify(context.manifest)}`} />
<script
type="module"
Expand Down
4 changes: 4 additions & 0 deletions packages/start/src/server/spa/handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createBaseHandler } from "../handler";
import { FetchEvent, HandlerOptions, PageEvent } from "../types";
import { loadInitialData } from "./initial-data";

/**
*
Expand All @@ -15,6 +16,7 @@ export function createHandler(

export async function createPageEvent(ctx: FetchEvent) {
const clientManifest = import.meta.env.MANIFEST["client"]!;
ctx.response.headers.set("Content-Type", "text/html");
const pageEvent: PageEvent = Object.assign(ctx, {
manifest: await clientManifest.json(),
assets: [...(await clientManifest.inputs[clientManifest.handler]!.assets())],
Expand All @@ -26,5 +28,7 @@ export async function createPageEvent(ctx: FetchEvent) {
$islands: new Set<string>()
});

if (import.meta.env.START_SSR_DATA) await loadInitialData(pageEvent);

return pageEvent;
}
3 changes: 3 additions & 0 deletions packages/start/src/server/spa/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@
export * from "../types";
export { StartServer } from "./StartServer";
export { createHandler } from "./handler";
import { getServerFunctionMeta as getServerFunctionMeta_ } from "../../shared/serverFunction";

/** @deprecated */
export const getServerFunctionMeta = getServerFunctionMeta_;
Loading
Loading