Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion src/html/hydration-script-builder/dev-client-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 } from 'veryfront/router';
import { RouterProvider, useRouter as useRouterFromModule, getNavigationStore } from 'veryfront/router';
import { PageContextProvider } from 'veryfront/context';

${getRouterScript()}
Expand Down
2 changes: 1 addition & 1 deletion src/html/hydration-script-builder/prod-scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function generateProdHydrationModule(): string {
return [
`import * as React from 'react';`,
`import { createRoot } from 'react-dom/client';`,
`import { RouterProvider, useRouter as useRouterFromModule } from 'veryfront/router';`,
`import { RouterProvider, useRouter as useRouterFromModule, getNavigationStore } from 'veryfront/router';`,
`import { PageContextProvider } from 'veryfront/context';`,
getRouterScript().trim(),
getLoaderScript().trim(),
Expand Down
203 changes: 203 additions & 0 deletions src/html/hydration-script-builder/templates/router-push-spa.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import "#veryfront/schemas/_test-setup.ts";
import { assertEquals } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { getRouterScript } from "./router.ts";

// Finding #7: useRouter().push()/replace() must perform SPA navigation, not a
// full document reload. `useRouter()` (from `veryfront/router` = react runtime
// core) routes push/replace/navigate through the shared navigation store's
// `navigate`, which delegates to whatever navigator has been registered via
// `setNavigator` — or falls back to `location.assign` (a full reload) when none
// is. The dev hydration runtime owns the real SPA navigator (`navigateSPA`, the
// same one that intercepts <Link> clicks), so it must register that navigator
// against the shared store. These tests evaluate the generated runtime with a
// stub store and assert the registration + SPA routing actually happen.

interface RuntimeLocation {
origin: string;
pathname: string;
search: string;
readonly href: string;
}
interface RuntimeRouter {
pathname: string;
push(path: string): void;
replace(path: string): void;
}
interface FakeStore {
navigator: ((href: string, options?: { history?: string }) => Promise<void>) | null;
assignFallbackCount: number;
navigate(href: string, options?: { history?: string }): Promise<void>;
setNavigator(next: (href: string, options?: { history?: string }) => Promise<void>): void;
}

interface RuntimeHandle {
router: RuntimeRouter;
navigateSPA: (href: string, pushState?: boolean, restoreScroll?: boolean) => Promise<void>;
store: FakeStore;
win: { __veryfrontHydrationComplete?: () => void };
setNextPageData: (data: unknown) => void;
}

function evaluateRouterRuntimeWithStore(): RuntimeHandle {
const listeners: Record<string, Array<(e: unknown) => void>> = {};
const addEventListener = (type: string, fn: (e: unknown) => void) => {
(listeners[type] ??= []).push(fn);
};

const makeEl = () => ({
style: {} as Record<string, unknown>,
id: "",
textContent: "",
setAttribute() {},
getAttribute() {
return null;
},
prepend() {},
remove() {},
appendChild() {},
});

const rootEl = { __reactRoot: { render() {} } };
const hydrationJson = JSON.stringify({ params: {} });
const doc = {
readyState: "complete",
body: { prepend() {}, setAttribute() {}, removeAttribute() {}, appendChild() {} },
head: { appendChild() {} },
createElement: () => makeEl(),
querySelector: () => null,
querySelectorAll: () => [] as unknown[],
getElementById: (id: string) => {
if (id === "veryfront-hydration-data") return { textContent: hydrationJson };
if (id === "root") return rootEl;
return null;
},
addEventListener,
};

const win = {
location: {
origin: "https://veryfront.test",
pathname: "/",
search: "",
get href() {
return "https://veryfront.test" + this.pathname + this.search;
},
} as RuntimeLocation,
history: { pushState() {}, replaceState() {}, back() {}, forward() {} },
addEventListener,
dispatchEvent() {
return true;
},
scrollTo() {},
scrollY: 0,
__veryfrontRouter: undefined as RuntimeRouter | undefined,
__veryfrontHydrationComplete: undefined as (() => void) | undefined,
};

let nextPageData: unknown = { pagePath: "page", params: {} };
const fetchStub = () =>
Promise.resolve({
ok: true,
status: 200,
url: "/_veryfront/page-data/page.json",
headers: { get: () => null },
json: () => Promise.resolve(nextPageData),
});

const RouterProvider = () => ({});
const PageContextProvider = () => ({});
const React = { createElement: () => ({}) };
const loadComponent = () => Promise.resolve(() => null);

// Faithful stand-in for the cross-bundle navigation store the react runtime's
// RouterProvider reads. `navigate` delegates to the registered navigator, or
// records a full-reload fallback when none is registered — exactly the real
// store's `navigate(href) { if (navigator) return navigator(...); location.assign(...) }`.
const store: FakeStore = {
navigator: null,
assignFallbackCount: 0,
navigate(href, options) {
if (store.navigator) return store.navigator(href, options);
store.assignFallbackCount++;
return Promise.resolve();
},
setNavigator(next) {
store.navigator = next;
},
};
const getNavigationStore = () => store;

const factory = new Function(
"window",
"document",
"fetch",
"React",
"RouterProvider",
"PageContextProvider",
"loadComponent",
"setTimeout",
"clearTimeout",
"getNavigationStore",
getRouterScript() + "\nreturn { router, navigateSPA };",
);

const handle = factory(
win,
doc,
fetchStub,
React,
RouterProvider,
PageContextProvider,
loadComponent,
() => 0,
() => {},
getNavigationStore,
) as { router: RuntimeRouter; navigateSPA: RuntimeHandle["navigateSPA"] };

return {
router: handle.router,
navigateSPA: handle.navigateSPA,
store,
win,
setNextPageData: (data: unknown) => {
nextPageData = data;
},
};
}

describe("hydration-script-builder/templates/router — push SPA navigator (finding #7)", () => {
it("registers the SPA navigator against the shared navigation store", () => {
const runtime = evaluateRouterRuntimeWithStore();
// Without a registered navigator, useRouter().push() falls back to a full
// document reload (location.assign). The runtime must register navigateSPA.
assertEquals(typeof runtime.store.navigator, "function");
});

it("routes store.navigate({history:'push'}) through SPA navigation, not a full reload", async () => {
const runtime = evaluateRouterRuntimeWithStore();
runtime.win.__veryfrontHydrationComplete?.();
runtime.setNextPageData({ pagePath: "page", params: {} });
runtime.win.location.pathname = "/next";

// This is exactly what useRouter().push('/next') does in the react runtime.
await runtime.store.navigate("/next", { history: "push" });

// SPA navigation ran (router snapshot moved) and the store never fell back
// to the full-reload path.
assertEquals(runtime.store.assignFallbackCount, 0);
assertEquals(runtime.router.pathname, "/next");
});

it("routes store.navigate({history:'replace'}) through SPA navigation, not a full reload", async () => {
const runtime = evaluateRouterRuntimeWithStore();
runtime.win.__veryfrontHydrationComplete?.();
runtime.setNextPageData({ pagePath: "page", params: {} });
runtime.win.location.pathname = "/replaced";

await runtime.store.navigate("/replaced", { history: "replace" });

assertEquals(runtime.store.assignFallbackCount, 0);
assertEquals(runtime.router.pathname, "/replaced");
});
});
2 changes: 2 additions & 0 deletions src/html/hydration-script-builder/templates/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ describe("hydration-script-builder/templates/router", () => {
"loadComponent",
"setTimeout",
"clearTimeout",
"getNavigationStore",
getRouterScript() + "\nreturn { router, navigateSPA };",
);

Expand All @@ -612,6 +613,7 @@ describe("hydration-script-builder/templates/router", () => {
loadComponent,
() => 0,
() => {},
() => ({ setNavigator() {} }),
) as { router: RuntimeRouter; navigateSPA: RuntimeHandle["navigateSPA"] };

return {
Expand Down
9 changes: 9 additions & 0 deletions src/html/hydration-script-builder/templates/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,15 @@ export const getRouterScript = () => `

window.__veryfrontRouter = router;

// Route useRouter().push/replace/navigate (from veryfront/router) through the
// 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;
return navigateSPA(href, mode !== 'replace' && mode !== 'none');
Comment thread
kojiwakayama marked this conversation as resolved.
Outdated
});

// ============================================
// Event handlers
// ============================================
Expand Down
2 changes: 1 addition & 1 deletion src/react/runtime/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ interface NavigationStore {

const NAVIGATION_STORE_KEY = Symbol.for("veryfront.navigation.store.v1");

function getNavigationStore(): NavigationStore {
export function getNavigationStore(): NavigationStore {
const holder = globalThis as Record<symbol, unknown>;
const existing = holder[NAVIGATION_STORE_KEY] as NavigationStore | undefined;
if (existing) return existing;
Expand Down
Loading