Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.1156",
"version": "0.1.1157",
"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/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.1156";
export const VERSION = "0.1.1157";
80 changes: 53 additions & 27 deletions tests/_helpers/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ interface BrowserBridgeMessage {
wsEndpoint: string;
}

const browserBridgeCleanups = new WeakMap<Browser, () => Promise<void>>();

export function parseBrowserBridgeMessage(message: string): BrowserBridgeMessage {
let parsed: unknown;
try {
Expand All @@ -61,12 +63,17 @@ async function readBrowserBridgeMessage(
const decoder = new TextDecoder();
let output = "";

while (true) {
const { done, value } = await reader.read();
if (done) throw new Error("Playwright browser bridge exited before reporting an endpoint");
output += decoder.decode(value, { stream: true });
const newline = output.indexOf("\n");
if (newline >= 0) return parseBrowserBridgeMessage(output.slice(0, newline));
try {
while (true) {
const { done, value } = await reader.read();
if (done) throw new Error("Playwright browser bridge exited before reporting an endpoint");
output += decoder.decode(value, { stream: true });
const newline = output.indexOf("\n");
if (newline >= 0) return parseBrowserBridgeMessage(output.slice(0, newline));
}
} finally {
await reader.cancel();
reader.releaseLock();
}
}

Expand All @@ -92,13 +99,37 @@ async function launchChromiumThroughNode(chromium: ChromiumConnector): Promise<B
stdout: "piped",
stderr: "piped",
}).spawn();
const statusPromise = child.status;
const stderrPromise = new Response(child.stderr).text();
let cleanupPromise: Promise<void> | undefined;
const cleanup = (): Promise<void> => {
cleanupPromise ??= (async () => {
try {
const writer = child.stdin.getWriter();
try {
await writer.close();
} finally {
writer.releaseLock();
}
} catch {
try {
child.kill("SIGTERM");
} catch {
/* expected: bridge may already have exited */
}
}

await statusPromise;
await stderrPromise;
Comment thread
kwakayama marked this conversation as resolved.
Outdated
})();
return cleanupPromise;
};

try {
const bridge = await withTimeout(
Promise.race([
readBrowserBridgeMessage(child.stdout),
child.status.then(async (status) => {
statusPromise.then(async (status) => {
const stderr = (await stderrPromise).trim();
throw new Error(
`Playwright browser bridge exited with code ${status.code}${
Expand All @@ -114,31 +145,14 @@ async function launchChromiumThroughNode(chromium: ChromiumConnector): Promise<B
timeout: CHROMIUM_LAUNCH_TIMEOUT_MS,
});

let cleanedUp = false;
browserBridgeCleanups.set(browser, cleanup);
browser.on("disconnected", () => {
if (cleanedUp) return;
cleanedUp = true;
void (async () => {
try {
const writer = child.stdin.getWriter();
await writer.close();
} catch {
try {
child.kill("SIGTERM");
} catch {
/* expected: bridge may already have exited */
}
}
})();
void cleanup();
});

return browser;
} catch (error) {
try {
child.kill("SIGTERM");
} catch {
/* expected: bridge may already have exited */
}
await cleanup();
throw error;
}
}
Expand Down Expand Up @@ -179,6 +193,18 @@ export async function launchChromium(): Promise<Browser | null> {
}
}

/** Close a browser and await any Node bridge subprocess cleanup. */
export async function closeChromium(browser: Browser | null): Promise<void> {
if (!browser) return;
const cleanup = browserBridgeCleanups.get(browser);
try {
await browser.close();
} finally {
await cleanup?.();
browserBridgeCleanups.delete(browser);
}
}

export function captureBrowserDiagnostics(page: Page): BrowserDiagnostics {
const consoleMessages: string[] = [];
const pageErrors: string[] = [];
Expand Down
Loading