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/spa-server-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": patch
---

Export `getServerFunctionMeta`, `decorateHandler` and `decorateMiddleware` from `@solidjs/start/server` in SPA mode (`ssr: false`), so apps using them build.
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.
8 changes: 7 additions & 1 deletion packages/start/src/client/spa/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
// @refresh skip
import type { JSX } from "solid-js";
import { render, type MountableElement } from "solid-js/web";
import { seedInitialData } from "./initial-data.ts";

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 } from "../StartClient.tsx";
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.ts";

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;
};
}
12 changes: 8 additions & 4 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ export interface SolidStartOptions {
*
* - `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;
ssr?: boolean | "data-only";

/**
* Show the SolidStart development overlay (error overlay, etc.) in development.
Expand Down Expand Up @@ -371,7 +374,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
resolve: {
alias: {
"@solidjs/start/server/entry": handlers.server,
...(!start.ssr
...(start.ssr !== true
? {
"@solidjs/start/server": "@solidjs/start/server/spa",
"@solidjs/start/client": "@solidjs/start/client/spa",
Expand All @@ -391,7 +394,8 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
},
define: {
"import.meta.env.MANIFEST": `globalThis.MANIFEST`,
"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"),
// Use JSON.stringify so backslashes on Windows are escaped and
// esbuild receives a valid JS string literal for the define value
"import.meta.env.START_APP_ENTRY": JSON.stringify(appEntryPath),
Expand Down Expand Up @@ -432,7 +436,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
ssr: new SolidStartServerFileRouter({
dir: absolute(routeDir, root),
extensions,
dataOnly: !start.ssr,
dataOnly: start.ssr !== true,
}),
},
}),
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 @@ -9,6 +9,7 @@ interface ImportMetaEnv extends SolidStartMetaEnv {}

interface SolidStartMetaEnv {
START_SSR: boolean;
START_SSR_DATA: boolean;
START_APP_ENTRY: string;
START_CLIENT_ENTRY: string;
START_CLIENT_ENTRY_URL: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/start/src/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function createBaseHandler(
const mode = resolvedOptions.mode || "stream";
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
17 changes: 16 additions & 1 deletion packages/start/src/server/spa/StartServer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// @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 { getSsrManifest } from "../manifest/ssr-manifest.ts";

import { TopErrorBoundary } from "../../shared/ErrorBoundary.tsx";
import { useAssets } from "../assets/index.ts";
import PatchVirtualDevStyles from "../assets/PatchVirtualDevStyles.tsx";
import { InitialData } from "./initial-data.ts";
import type { DocumentComponentProps, PageEvent } from "../types.ts";

const docType = ssr("<!DOCTYPE html>");
Expand All @@ -26,9 +27,23 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
{docType as unknown as any}
<TopErrorBoundary>
<props.document
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-expect-error
<HydrationScript eventNames={[]} />
) : undefined
}
scripts={
<>
<PatchVirtualDevStyles nonce={nonce} />
{import.meta.env.START_SSR_DATA && (
<Hydration>
<InitialData />
</Hydration>
)}
<script
type="module"
nonce={nonce}
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
Expand Up @@ -3,6 +3,7 @@ import type { JSX } from "solid-js";
import { createBaseHandler } from "../handler.ts";
import { getSsrManifest } from "../manifest/ssr-manifest.ts";
import type { FetchEvent, HandlerOptions, PageEvent, StartHandler } from "../types.ts";
import { loadInitialData } from "./initial-data.ts";

/**
*
Expand All @@ -17,6 +18,7 @@ export function createHandler(
}

async function createPageEvent(ctx: FetchEvent) {
ctx.response.headers.set("Content-Type", "text/html");
const manifest = getSsrManifest("ssr");
const pageEvent: PageEvent = Object.assign(ctx, {
manifest: "json" in manifest ? await manifest.json() : {},
Expand All @@ -26,5 +28,7 @@ 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
@@ -1,5 +1,8 @@
// @refresh skip
export { default as lazy } from "../../shared/lazy.ts";
export { getServerFunctionMeta } from "../../shared/serverFunction.ts";
export * from "../types.ts";
export { StartServer } from "./StartServer.tsx";
export { decorateHandler, decorateMiddleware } from "../fetchEvent.ts";
export { createHandler } from "./handler.ts";
export type { ServerFunctionErrorHandler } from "../../fns/error-handler.ts";
165 changes: 165 additions & 0 deletions packages/start/src/server/spa/initial-data.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { sharedConfig } from "solid-js";
import { getRequestEvent } from "solid-js/web";
import { beforeEach, describe, expect, it, vi } from "vitest";

import type { PageEvent } from "../types.ts";
import { InitialData, loadInitialData } from "./initial-data.ts";

vi.mock("solid-js/web", async () => {
const { sharedConfig } = await import("solid-js");
return {
renderToString: (fn: () => unknown) => {
sharedConfig.context = {} as any;
try {
return fn();
} finally {
sharedConfig.context = undefined;
}
},
getRequestEvent: vi.fn(),
};
});

vi.mock("../../router.tsx", () => ({
createRoutes: () => [],
}));

// Stands in for the router: in data-only mode it records each query call in
// `event.router.data`, the way `query` does.
let queries: Record<string, () => unknown> = {};
let currentEvent: PageEvent;
vi.mock("solid-start:app", () => ({
default: () => {
const router = (currentEvent as any).router;
if (!router.dataOnly) return;
router.data ||= {};
for (const key in queries) router.data[key] = queries[key]!();
},
}));

function createEvent(): PageEvent {
return {
request: new Request("http://localhost/page"),
response: { headers: new Headers() },
} as unknown as PageEvent;
}

function serializeInitialData(event: PageEvent, async = true) {
const serialize = vi.fn();
vi.mocked(getRequestEvent).mockReturnValue(event);
sharedConfig.context = { async, serialize } as any;
try {
InitialData();
} finally {
sharedConfig.context = undefined;
}
return Object.fromEntries(
serialize.mock.calls.map(([key, value]) => [key, value as Promise<unknown>]),
);
}

describe("ssr: data-only initial data", () => {
beforeEach(() => {
queries = {};
currentEvent = createEvent();
});

it("streams the result of each query the page's preloads made", async () => {
queries = {
'user["1"]': () => Promise.resolve({ id: 1 }),
"list[]": () => Promise.resolve([1, 2]),
};
await loadInitialData(currentEvent);

const data = serializeInitialData(currentEvent);
expect(Object.keys(data)).toEqual(['user["1"]', "list[]"]);
await expect(data['user["1"]']).resolves.toEqual({ id: 1 });
await expect(data["list[]"]).resolves.toEqual([1, 2]);
});

it("leaves the router out of data-only mode afterwards", async () => {
queries = { "a[]": () => Promise.resolve(1) };
await loadInitialData(currentEvent);
expect((currentEvent as any).router.dataOnly).toBeUndefined();
expect((currentEvent as any).router.data).toBeUndefined();
});

it("skips queries the router left out", async () => {
queries = { "a[]": () => undefined, "b[]": () => Promise.resolve(2) };
await loadInitialData(currentEvent);
expect(Object.keys(serializeInitialData(currentEvent))).toEqual(["b[]"]);
});

it("sends the body of a json() response and keeps its headers except the body ones", async () => {
queries = {
"a[]": () => {
const response = new Response(null, {
headers: {
"content-type": "application/json",
"set-cookie": "a=1",
"x-custom": "yes",
},
});
(response as any).customBody = () => ({ ok: true });
return Promise.resolve(response);
},
};
currentEvent.response.headers.set("content-type", "text/html");
currentEvent.response.headers.append("set-cookie", "b=2");
await loadInitialData(currentEvent);

await expect(serializeInitialData(currentEvent)["a[]"]).resolves.toEqual({ ok: true });
expect(currentEvent.response.headers.get("content-type")).toBe("text/html");
expect(currentEvent.response.headers.getSetCookie()).toEqual(["b=2", "a=1"]);
expect(currentEvent.response.headers.get("x-custom")).toBe("yes");
});

it("turns a returned redirect into a redirect of the page", async () => {
queries = {
"a[]": () =>
Promise.resolve(new Response(null, { status: 307, headers: { Location: "/login" } })),
};
await loadInitialData(currentEvent);

await expect(serializeInitialData(currentEvent)["a[]"]).resolves.toBeUndefined();
expect(currentEvent.response.headers.get("Location")).toBe("/login");
expect(currentEvent.response.status).toBe(307);
});

it("does not report a thrown redirect as an error", async () => {
queries = {
"a[]": () => Promise.reject(new Response(null, { status: 302, headers: { Location: "/" } })),
};
await loadInitialData(currentEvent);

await expect(serializeInitialData(currentEvent)["a[]"]).resolves.toBeUndefined();
expect(currentEvent.response.headers.get("Location")).toBe("/");
});

it("streams a failed query as a rejection", async () => {
const error = new Error("boom");
queries = { "a[]": () => Promise.reject(error) };
await loadInitialData(currentEvent);

await expect(serializeInitialData(currentEvent)["a[]"]).rejects.toBe(error);
});

it("sends nothing from a render that can't stream", async () => {
queries = { "a[]": () => Promise.resolve(1) };
await loadInitialData(currentEvent);
expect(serializeInitialData(currentEvent, false)).toEqual({});
});

it("sends nothing when the root can't render on the server", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
queries = {
"a[]": () => {
throw new Error("window is not defined");
},
};
await loadInitialData(currentEvent);

expect(consoleError).toHaveBeenCalled();
expect(serializeInitialData(currentEvent)).toEqual({});
});
});
Loading
Loading