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

fix: resolve server functions in dev when their route was reached through client-side navigation before its module was evaluated on the server
40 changes: 36 additions & 4 deletions packages/start/src/directives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
type Plugin,
type ViteDevServer,
} from "vite";
import fg from "fast-glob";
import { compile, type CompileOptions } from "./compile.ts";
import xxHash32 from "./xxhash32.ts";

export interface ServerFunctionsFilter {
include?: FilterPattern;
Expand All @@ -25,6 +27,11 @@ const DEFAULT_INCLUDE = "src/**/*.{jsx,tsx,ts,js,mjs,cjs}";
const DEFAULT_EXCLUDE = "node_modules/**/*.{jsx,tsx,ts,js,mjs,cjs}";
const DIRECTIVE = "use server";

// Dev-only virtual module used by fns/handler.ts to lazily resolve a server
// function id back to its owning module when the function was never evaluated
// in the server environment (e.g. its route was only client-side navigated to).
const LOOKUP_ID = "solid-start:server-fn-lookup";

type Manifest = Record<CompileOptions["mode"], Set<string>>;

function createManifest(): Manifest {
Expand Down Expand Up @@ -107,13 +114,15 @@ function invalidateModules(
): void {
if (server) {
if (result.invalidPreload) {
invalidateModule(server.environments.client.moduleGraph, manifest);
invalidateModule(server.environments.ssr.moduleGraph, manifest);
// Environments are not limited to "client"/"ssr": plugins like nitro
// render in their own environment (e.g. "nitro"), so invalidate everywhere.
for (const environment of Object.values(server.environments)) {
invalidateModule(environment.moduleGraph, manifest);
}
}
}
}


export function serverFunctionsPlugin(options: ServerFunctionsOptions): Plugin[] {
const filter = createFilter(
options.filter?.include || DEFAULT_INCLUDE,
Expand Down Expand Up @@ -179,6 +188,9 @@ export function serverFunctionsPlugin(options: ServerFunctionsOptions): Plugin[]
if (source === options.manifest) {
return { id: options.manifest, moduleSideEffects: true };
}
if (source.startsWith(LOOKUP_ID)) {
return { id: source, moduleSideEffects: true };
}
return null;
},
async load(id) {
Expand All @@ -191,12 +203,32 @@ export function serverFunctionsPlugin(options: ServerFunctionsOptions): Plugin[]
const result = await current.promise.reference;
return result;
}
if (id.startsWith(LOOKUP_ID)) {
if (this.environment.mode !== "dev") {
throw new Error(`${LOOKUP_ID} is only available in dev`);
}
const functionId = new URLSearchParams(id.slice(LOOKUP_ID.length + 1)).get("id");
// dev function ids are `${xxHash32(file)}-${count}-${name}`
const hash = functionId?.split("-")[0];
if (!hash) return "export {};";

// Look through the files already discovered by the transform first;
// fall back to scanning the project so a function is found even when
// the browser kept a chunk from before a dev server restart.
let files = [...manifest.server].filter(file => xxHash32(file).toString(16) === hash);
if (files.length === 0) {
files = fg
.sync(DEFAULT_INCLUDE, { cwd: this.environment.config.root, absolute: true })
.filter(file => filter(file) && xxHash32(file).toString(16) === hash);
}
return files.map(file => `import ${JSON.stringify(file)};`).join("\n") || "export {};";
}
return null;
},
},
{
name: "solid-start:server-functions/compiler",
enforce: 'pre',
enforce: "pre",
async transform(code, fileId) {
const mode = this.environment.config.consumer;
const [id] = fileId.split("?");
Expand Down
27 changes: 20 additions & 7 deletions packages/start/src/fns/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,10 @@ import {
serializeToJSONStream,
serializeToJSStream,
} from "./serialization.ts";
import {
BODY_FORMAT_KEY,
BodyFormat,
extractBody,
getHeadersAndBody,
} from "./shared.ts";
import { BODY_FORMAT_KEY, BodyFormat, extractBody, getHeadersAndBody } from "./shared.ts";
import "solid-start:server-fn-manifest";

import { getServerFunction } from "./registration.ts";
import { getServerFunction, hasServerFunction } from "./registration.ts";
import type { FetchEvent, PageEvent } from "../server/types.ts";
import { getExpectedRedirectStatus } from "../server/util.ts";

Expand All @@ -45,6 +40,24 @@ export async function handleServerFunction(h3Event: H3Event) {
}
}

if (import.meta.env.DEV && !hasServerFunction(functionId!)) {
// The module that owns this function has not been evaluated in the server
// environment yet (e.g. the route was reached by client-side navigation, so
// it was only ever loaded in the browser). Ask the dev server to resolve the
// function id back to its owning module and import it, which registers it.
// Resolved by the "solid-start:server-functions/preload" plugin.
try {
// the /@id/ prefix routes the request through the plugin container so the
// virtual module can be resolved (bare ids get node-resolved by the runner)
await import(
/* @vite-ignore */ `/@id/solid-start:server-fn-lookup?id=${encodeURIComponent(functionId!)}`
);
} catch (error) {
// fall through to getServerFunction, which reports the missing function
console.error("[solid-start] server function lookup failed:", error);
}
}

const serverFunction = getServerFunction(functionId!);

let parsed: any[] = [];
Expand Down
10 changes: 6 additions & 4 deletions packages/start/src/fns/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ export function registerServerFunction<T extends any[], R>(
return callback;
}

export function getServerFunction<T extends any[], R>(
id: string,
): ((...args: T) => Promise<R>) {
export function hasServerFunction(id: string): boolean {
return REGISTRATIONS.has(id);
}

export function getServerFunction<T extends any[], R>(id: string): (...args: T) => Promise<R> {
const fn = REGISTRATIONS.get(id) as ((...args: T) => Promise<R>) | undefined;
if (fn) {
return fn;
}
throw new Error('invalid server function: ' + id);
throw new Error("invalid server function: " + id);
}
Loading