Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js"
import { dataUrlCssPlugin } from "./plugins/css-data-url.js";
import { createCssModuleImportCompatibilityPlugin } from "./plugins/css-module-imports.js";
import { createRscClientReferenceLoadersPlugin } from "./plugins/rsc-client-reference-loaders.js";
import { createRscReferenceValidationNormalizerPlugin } from "./plugins/rsc-reference-validation-normalizer.js";
import { createInstrumentationClientTransformPlugin } from "./plugins/instrumentation-client.js";
import { createStyledJsxPlugin } from "./plugins/styled-jsx.js";
import {
Expand Down Expand Up @@ -6213,6 +6214,7 @@ export const loadServerActionClient = ${
// Append auto-injected RSC plugins if applicable
if (rscPluginPromise) {
plugins.push(rscPluginPromise);
plugins.push(createRscReferenceValidationNormalizerPlugin());
plugins.push(createRscClientReferenceLoadersPlugin());
}

Expand Down
34 changes: 28 additions & 6 deletions packages/vinext/src/plugins/rsc-client-reference-loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ import type { PluginApi } from "@vitejs/plugin-rsc";

const CLIENT_REFERENCES_ID = "\0virtual:vite-rsc/client-references";
const RESOLVED_ID_PROXY_PREFIX = "virtual:vite-rsc/resolved-id/";
const DEV_CLIENT_REFERENCE_WARMUP_IDS = Object.freeze([
"virtual:vite-rsc/remove-duplicate-server-css",
]);

type RscClientReferenceMeta = PluginApi["manager"]["clientReferenceMetaMap"][string];

type RscPluginWithApi = Plugin & {
api?: PluginApi;
};

function getRscApi(plugins: readonly Plugin[]): PluginApi | undefined {
return (plugins.find((plugin) => plugin.name === "rsc:minimal") as RscPluginWithApi | undefined)
?.api;
}

function withResolvedIdProxy(resolvedId: string): string {
return resolvedId.startsWith("\0")
? RESOLVED_ID_PROXY_PREFIX + encodeURIComponent(resolvedId)
Expand Down Expand Up @@ -46,24 +54,38 @@ function generateDirectClientReferenceLoaders(metas: RscClientReferenceMeta[]):
return `export default {\n${entries}\n};\n`;
}

async function warmupRscDevClientReferences(rscApi: PluginApi | undefined): Promise<void> {
const manager = rscApi?.manager;
if (!manager || manager.config.command !== "serve") return;

const rscEnv = manager.server?.environments["rsc"];
if (!rscEnv) return;

for (const source of DEV_CLIENT_REFERENCE_WARMUP_IDS) {
const resolved = await rscEnv.pluginContainer.resolveId(source, undefined);
await rscEnv.transformRequest(resolved?.id ?? source);
}
}

export function createRscClientReferenceLoadersPlugin(): Plugin {
let rscApi: PluginApi | undefined;
let devClientReferenceWarmup: Promise<void> | null = null;

return {
name: "vinext:rsc-client-reference-loaders",
enforce: "post",
configResolved(config) {
rscApi = (
config.plugins.find((plugin) => plugin.name === "rsc:minimal") as
| RscPluginWithApi
| undefined
)?.api;
rscApi = getRscApi(config.plugins);
},
transform(_code, id) {
async transform(_code, id) {
if (id !== CLIENT_REFERENCES_ID) return null;

const manager = rscApi?.manager;
if (!manager || manager.isScanBuild) return null;
if (!devClientReferenceWarmup) {
devClientReferenceWarmup = warmupRscDevClientReferences(rscApi).catch(() => {});
}
await devClientReferenceWarmup;

// This post-transform runs after @vitejs/plugin-rsc has loaded the
// client-reference virtual module and populated the manager metadata. The
Expand Down
1 change: 1 addition & 0 deletions packages/vinext/src/plugins/rsc-client-shim-excludes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const RSC_CLIENT_SHIM_OPTIMIZE_DEPS_EXCLUDE = Object.freeze([
// bare source. If Vite pre-bundles these known client shims, the generated
// client-package proxy can lose the matching export metadata in dev.
"vinext/shims/error-boundary",
"vinext/shims/default-global-error",
"vinext/shims/form",
"vinext/shims/layout-segment-context",
"vinext/shims/link",
Expand Down
74 changes: 74 additions & 0 deletions packages/vinext/src/plugins/rsc-reference-validation-normalizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Plugin } from "vite";
import type { PluginApi } from "@vitejs/plugin-rsc";

const REFERENCE_VALIDATION_ID_PREFIX = "\0virtual:vite-rsc/reference-validation?";

type RscPluginWithApi = Plugin & {
api?: PluginApi;
};

type RscReferenceMeta =
| PluginApi["manager"]["clientReferenceMetaMap"][string]
| PluginApi["manager"]["serverReferenceMetaMap"][string];

function parseReferenceValidationQuery(id: string): { type?: string; id?: string } | null {
const queryStart = id.indexOf("?");
if (queryStart === -1) return null;
return Object.fromEntries(new URLSearchParams(id.slice(queryStart + 1)));
}

function normalizeReferenceKey(id: string): string {
return id.replaceAll("\0", "__x00__").replace(/\$\$cache=.*$/, "");
}

function hasReference(
referenceMetaMap: Record<string, RscReferenceMeta> | undefined,
referenceId: string | undefined,
): boolean {
if (!referenceMetaMap || !referenceId) return false;

const normalizedReferenceId = normalizeReferenceKey(referenceId);
return Object.values(referenceMetaMap).some(
(meta) => normalizeReferenceKey(meta.referenceKey) === normalizedReferenceId,
);
}

/**
* @vitejs/plugin-rsc stores dev virtual client-reference keys in Vite's encoded
* `/@id/__x00__...` form, but React's SSR consumer can ask validation for the
* decoded `/@id/\0...` form. Treat those as equivalent and fall through to the
* upstream validator for all other invalid references.
*/
export function createRscReferenceValidationNormalizerPlugin(): Plugin {
let rscApi: PluginApi | undefined;

return {
name: "vinext:rsc-reference-validation-normalizer",
enforce: "pre",
apply: "serve",
configResolved(config) {
rscApi = (
config.plugins.find((plugin) => plugin.name === "rsc:minimal") as
| RscPluginWithApi
| undefined
)?.api;
},
load(id) {
if (!id.startsWith(REFERENCE_VALIDATION_ID_PREFIX)) return null;

const query = parseReferenceValidationQuery(id);
if (!query) return null;

const manager = rscApi?.manager;
if (query.type === "client" && hasReference(manager?.clientReferenceMetaMap, query.id)) {
return "export {}";
}

if (query.type === "server" && hasReference(manager?.serverReferenceMetaMap, query.id)) {
return "export {}";
}

return null;
},
};
}
27 changes: 15 additions & 12 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,13 @@ import {
} from "./app-browser-state.js";
import { AppBrowserHistoryController } from "./app-browser-history-controller.js";
import {
clearVisitedResponseCache,
createVisitedResponseCacheEntry,
deleteVisitedResponseCacheEntry,
findVisitedResponseCacheEntry,
isVisitedResponseCacheEntryCompatibleForNavigation,
isVisitedResponseCacheEntryFresh,
visitedResponseCache,
type VisitedResponseCacheEntry,
} from "./app-visited-response-cache.js";
import {
Expand Down Expand Up @@ -318,7 +323,6 @@ function isRouterStatePromise(
}

let latestClientParams: Record<string, string | string[]> = {};
const visitedResponseCache = new Map<string, VisitedResponseCacheEntry>();
let clientNavigationCacheGeneration = 0;
// Sticky bit: stays true once BrowserRoot has committed at least once. Used by
// the HMR handler to distinguish "still hydrating" (wait) from "was up, then
Expand Down Expand Up @@ -407,10 +411,6 @@ function stageClientParams(params: Record<string, string | string[]>): void {
replaceClientParamsWithoutNotify(params);
}

function clearVisitedResponseCache(): void {
visitedResponseCache.clear();
}

function clearPrefetchState(): void {
invalidatePrefetchCache();
optimisticRouteTemplates.clear();
Expand Down Expand Up @@ -679,8 +679,8 @@ function readVisitedResponseCacheCandidate(
navigationKind: NavigationKind,
): VisitedResponseCacheCandidate {
const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext);
const cached = visitedResponseCache.get(cacheKey);
if (!cached) {
const match = findVisitedResponseCacheEntry(visitedResponseCache, rscUrl, interceptionContext);
if (!match) {
return {
cacheKey,
entry: null,
Expand All @@ -692,15 +692,18 @@ function readVisitedResponseCacheCandidate(
}

return {
cacheKey,
entry: cached,
cacheKey: match.cacheKey,
entry: match.entry,
facts: {
candidate: "present",
fresh: isVisitedResponseCacheEntryFresh(cached, {
fresh: isVisitedResponseCacheEntryFresh(match.entry, {
navigationKind,
now: Date.now(),
}),
mountedSlotsMatch: cached.mountedSlotsHeader === mountedSlotsHeader,
mountedSlotsMatch: isVisitedResponseCacheEntryCompatibleForNavigation(
match.entry,
mountedSlotsHeader,
),
navigationKind,
},
};
Expand Down Expand Up @@ -731,7 +734,7 @@ function applyVisitedResponseCacheCandidateDecision(
}

function deleteVisitedResponse(rscUrl: string, interceptionContext: string | null): void {
visitedResponseCache.delete(AppElementsWire.encodeCacheKey(rscUrl, interceptionContext));
deleteVisitedResponseCacheEntry(visitedResponseCache, rscUrl, interceptionContext);
}

function storeVisitedResponseSnapshot(
Expand Down
17 changes: 17 additions & 0 deletions packages/vinext/src/server/app-elements-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,10 @@ type AppElementsWireCodec = {
readonly unmatchedSlotValue: typeof APP_UNMATCHED_SLOT_WIRE_VALUE;
createMetadataEntries(input: AppElementsWireMetadataInput): AppElementsWireMetadataEntries;
decode(elements: AppWireElements): AppElements;
decodeCacheKey(input: string): {
interceptionContext: string | null;
rscUrl: string;
} | null;
encodeCacheKey(rscUrl: string, interceptionContext: string | null): string;
encodeLayoutId(treePath: string): string;
encodeOutgoingPayload(input: {
Expand Down Expand Up @@ -330,6 +334,18 @@ function createAppPayloadCacheKey(rscUrl: string, interceptionContext: string |
return appendInterceptionContext(rscUrl, interceptionContext);
}

function parseAppPayloadCacheKey(input: string): {
interceptionContext: string | null;
rscUrl: string;
} | null {
const parsed = parsePathWithInterception(input);
if (parsed === null) return null;
return {
interceptionContext: parsed.interceptionContext,
rscUrl: parsed.path,
};
}

function parsePathWithInterception(input: string): {
interceptionContext: string | null;
path: string;
Expand Down Expand Up @@ -884,6 +900,7 @@ export const AppElementsWire: AppElementsWireCodec = {
unmatchedSlotValue: APP_UNMATCHED_SLOT_WIRE_VALUE,
createMetadataEntries: createAppElementsWireMetadataEntries,
decode: normalizeAppElements,
decodeCacheKey: parseAppPayloadCacheKey,
encodeCacheKey: createAppPayloadCacheKey,
encodeLayoutId: createAppPayloadLayoutId,
encodeOutgoingPayload: buildOutgoingAppPayload,
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/server/app-ssr-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { AppRouterContext } from "vinext/shims/internal/app-router-context";
import { createClientReferencePreloader } from "./app-client-reference-preloader.js";
import { RSC_FORM_STATE_GLOBAL } from "./app-browser-hydration.js";
import { isPprFallbackShellAbortError } from "vinext/shims/ppr-fallback-shell";
import DefaultGlobalError from "vinext/shims/default-global-error";
import DefaultGlobalError from "vinext/shims/default-global-error-render";
import { appendAssetDeploymentIdQuery } from "../utils/deployment-id.js";
import { ssrAppRouterInstance } from "./app-ssr-router-instance.js";
// @ts-expect-error — resolved by the vinext build plugin in SSR environments.
Expand Down
Loading
Loading