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
4 changes: 2 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "veryfront",
"version": "0.1.1157",
"version": "0.1.1158",
"license": "Apache-2.0",
"nodeModulesDir": "auto",
"minimumDependencyAge": {
Expand Down Expand Up @@ -472,7 +472,7 @@
"test:all-runtimes": "deno task test:unit && deno task test:node && deno task test:bun",
"test:e2e": "deno task test:e2e:playwright",
"test:e2e:playwright": "PW_DISABLE_TS_ESM=1 npx playwright test --config=tests/e2e/playwright.config.cjs",
"test:e2e:rsc-browser": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --no-check --allow-all tests/e2e/regressions/rsc-proxy-hydration.test.ts --unstable-worker-options --unstable-net",
"test:e2e:rsc-browser": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --no-check --allow-all tests/e2e/regressions/rsc-proxy-hydration.test.ts tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts --unstable-worker-options --unstable-net",
"test:e2e:binary": "deno task generate && deno test --allow-all tests/integration/compiled-binary-e2e.test.ts",
"test:e2e:binary:fresh": "deno task generate && VERYFRONT_BINARY_FRESH=1 deno test --allow-all tests/integration/compiled-binary-e2e.test.ts",
"test:e2e:templates": "deno run --allow-all scripts/test/template-runtime-e2e.ts",
Expand Down
11 changes: 9 additions & 2 deletions src/html/hydration-script-builder/dev-client-renderer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { getLoaderScript, getRendererScript, getRouterScript } from "./templates/index.ts";
import {
getLoaderScript,
getNavigationStoreCompatibilityScript,
getRendererScript,
getRouterScript,
} from "./templates/index.ts";
import { buildNonceAttribute } from "../html-escape.ts";

export function generateDevClientRendererScript(nonce?: string): string {
Expand All @@ -8,8 +13,10 @@ export function generateDevClientRendererScript(nonce?: string): string {
<script type="module"${nonceAttr}>
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider, useRouter as useRouterFromModule, getNavigationStore } from 'veryfront/router';
import { RouterProvider, useRouter as useRouterFromModule } from 'veryfront/router';
import * as RouterRuntime from 'veryfront/router';
import { PageContextProvider } from 'veryfront/context';
${getNavigationStoreCompatibilityScript()}

${getRouterScript()}
${getLoaderScript()}
Expand Down
70 changes: 70 additions & 0 deletions src/html/hydration-script-builder/prod-scripts.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,69 @@
import "#veryfront/schemas/_test-setup.ts";
import { assertEquals } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import * as esbuild from "veryfront/extensions/bundler";
import { generateDevClientRendererScript } from "./dev-client-renderer.ts";
import {
generateProdHydrationModule,
getProdHydrationModulePath,
getProdScripts,
PROD_HYDRATION_MODULE_PATH,
} from "./prod-scripts.ts";

async function bundleHydrationModuleAgainstLegacyRouter(hydrationModule: string): Promise<void> {
const fixtureModules = new Map([
["react", "export function createElement() { return null; }"],
["react-dom/client", "export function createRoot() { return { render() {} }; }"],
[
"veryfront/router",
[
"export function RouterProvider({ children }) { return children; }",
"export function useRouter() { return {}; }",
].join("\n"),
],
[
"veryfront/context",
"export function PageContextProvider({ children }) { return children; }",
],
]);

await esbuild.build({
stdin: {
contents: hydrationModule,
sourcefile: "hydration-runtime.js",
loader: "js",
},
bundle: true,
write: false,
format: "esm",
platform: "browser",
// A missing namespace property is the compatibility path under test, so the
// expected esbuild warning is intentionally quiet. Any static named import
// still fails the build.
logLevel: "silent",
plugins: [{
name: "legacy-router-fixture",
setup(build) {
build.onResolve(
{ filter: /^(react|react-dom\/client|veryfront\/router|veryfront\/context)$/ },
(args) => ({ path: args.path, namespace: "legacy-router-fixture" }),
);
build.onLoad(
{ filter: /.*/, namespace: "legacy-router-fixture" },
(args) => ({ contents: fixtureModules.get(args.path), loader: "js" }),
);
},
}],
});
}

function extractModuleScript(scriptTag: string): string {
const start = scriptTag.indexOf(">") + 1;
const end = scriptTag.lastIndexOf("</script>");
if (start === 0 || end < start) throw new Error("Expected a module script tag");
return scriptTag.slice(start, end);
}

describe("hydration-script-builder/prod-scripts", () => {
describe("getProdScripts", () => {
it("should return an external module script tag with the versioned runtime path", () => {
Expand Down Expand Up @@ -81,3 +137,17 @@ describe("hydration-script-builder/prod-scripts", () => {
});
});
});

Deno.test({
name: "generated hydration modules link against router assets from existing releases",
async fn() {
try {
await bundleHydrationModuleAgainstLegacyRouter(generateProdHydrationModule());
await bundleHydrationModuleAgainstLegacyRouter(
extractModuleScript(generateDevClientRendererScript()),
);
} finally {
await esbuild.stop();
}
},
});
11 changes: 9 additions & 2 deletions src/html/hydration-script-builder/prod-scripts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { getLoaderScript, getRendererScript, getRouterScript } from "./templates/index.ts";
import {
getLoaderScript,
getNavigationStoreCompatibilityScript,
getRendererScript,
getRouterScript,
} from "./templates/index.ts";
import { buildNonceAttribute } from "../html-escape.ts";
import { fnv1aHash } from "#veryfront/utils/hash-utils.ts";

Expand All @@ -12,8 +17,10 @@ export function generateProdHydrationModule(): string {
return [
`import * as React from 'react';`,
`import { createRoot } from 'react-dom/client';`,
`import { RouterProvider, useRouter as useRouterFromModule, getNavigationStore } from 'veryfront/router';`,
`import { RouterProvider, useRouter as useRouterFromModule } from 'veryfront/router';`,
`import * as RouterRuntime from 'veryfront/router';`,
`import { PageContextProvider } from 'veryfront/context';`,
getNavigationStoreCompatibilityScript().trim(),
getRouterScript().trim(),
getLoaderScript().trim(),
getRendererScript().trim(),
Expand Down
1 change: 1 addition & 0 deletions src/html/hydration-script-builder/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

export { getRouterScript } from "./router.ts";
export { getNavigationStoreCompatibilityScript } from "./navigation-store.ts";
export { getLoaderScript } from "./loader.ts";
export { getRendererScript } from "./renderer.ts";
export { getSpaLoaderScript, getSpaRendererScript } from "./spa-renderer.ts";
53 changes: 53 additions & 0 deletions src/html/hydration-script-builder/templates/navigation-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Generate the compatibility bridge between the shared hydration runtime and
* release-pinned router assets.
*
* Router releases since the v1 navigation store landed share the same
* Symbol.for registry entry even when they predate the public
* getNavigationStore export. Keeping the fallback store shape in sync with the
* two v1 implementations lets those releases retain SPA navigation.
*/
export const getNavigationStoreCompatibilityScript = () => `
const navigationStoreUsesRegistryFallback =
typeof RouterRuntime.getNavigationStore !== 'function';
const getNavigationStore = navigationStoreUsesRegistryFallback
? () => {
const storeKey = Symbol.for('veryfront.navigation.store.v1');
const existing = globalThis[storeKey];
if (existing) return existing;

const listeners = new Set();
let navigator = null;
const store = {
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getHref() {
const loc = globalThis.location;
return loc ? loc.pathname + loc.search + loc.hash : '/';
},
notify() {
for (const listener of [...listeners]) {
try {
listener();
} catch {
// One subscriber must not prevent the others from updating.
}
}
},
navigate(href, options) {
if (navigator) return navigator(href, options);
globalThis.location?.assign(href);
return Promise.resolve();
},
setNavigator(next) {
navigator = next;
},
};

globalThis[storeKey] = store;
return store;
}
: RouterRuntime.getNavigationStore;
`;
18 changes: 13 additions & 5 deletions src/html/hydration-script-builder/templates/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1153,11 +1153,19 @@ export const getRouterScript = () => `
// same SPA navigator that intercepts <Link> clicks. Without this the shared
// navigation store has no navigator registered and its navigate() falls back
// to a full-page location.assign (finding #7: push() full-reloads).
getNavigationStore().setNavigator((href, options) => {
const mode = options && options.history;
const historyMode = mode === 'replace' ? 'replace' : mode === 'none' ? 'none' : 'push';
return navigateSPA(href, historyMode);
});
if (
typeof navigationStoreUsesRegistryFallback !== 'undefined' &&
navigationStoreUsesRegistryFallback
) {
log('Router runtime does not export getNavigationStore; using shared v1 registry fallback');
}
if (typeof getNavigationStore === 'function') {
getNavigationStore().setNavigator((href, options) => {
const mode = options && options.history;
const historyMode = mode === 'replace' ? 'replace' : mode === 'none' ? 'none' : 'push';
return navigateSPA(href, historyMode);
});
}

// ============================================
// Event handlers
Expand Down
2 changes: 1 addition & 1 deletion src/transforms/mdx/esm-module-loader/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ export async function clearESMDiskCache(): Promise<void> {

try {
// Remove entire cache directory and recreate it
// This handles nested project directories like codersociety/local-main/
// This handles nested project directories such as customer/local-main/
await fs.remove(cacheDir, { recursive: true });
await fs.mkdir(cacheDir, { recursive: true });
logger.debug(`${LOG_PREFIX_MDX_LOADER} Cleared ESM disk cache`);
Expand Down
2 changes: 1 addition & 1 deletion src/utils/version-constant.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Keep in sync with deno.json version.
// scripts/release.ts updates this constant during releases.
/** Shared version value. */
export const VERSION = "0.1.1157";
export const VERSION = "0.1.1158";
26 changes: 26 additions & 0 deletions tests/_helpers/playwright.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts";
import type { Browser } from "npm:playwright@1.60.0";
import {
CHROMIUM_LAUNCH_TIMEOUT_MS,
cleanupBrowserBridgeProcess,
launchChromiumWith,
parseBrowserBridgeMessage,
} from "./playwright.ts";
Expand Down Expand Up @@ -71,3 +72,28 @@ describe("launchChromiumWith", () => {
assertEquals(browser, null);
});
});

describe("cleanupBrowserBridgeProcess", () => {
it("force-kills a bridge that does not exit after stdin closes", async () => {
let resolveStatus: (status: Deno.CommandStatus) => void = () => {};
const statusPromise = new Promise<Deno.CommandStatus>((resolve) => {
resolveStatus = resolve;
});
const signals: Deno.Signal[] = [];

await cleanupBrowserBridgeProcess(
{
stdin: new WritableStream<Uint8Array>(),
kill(signal) {
signals.push(signal);
resolveStatus({ success: false, code: 137, signal });
},
},
statusPromise,
Promise.resolve(""),
10,
);

assertEquals(signals, ["SIGKILL"]);
});
});
Loading