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
1 change: 0 additions & 1 deletion example/context/server-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ describe("server-session context", () => {
it("should have sessionId as first 8 characters of a UUID", () => {
const session = createServerSession();

// UUID first segment is 8 hex characters
assertMatch(session.sessionId, /^[0-9a-f]{8}$/);
});
});
Expand Down
1 change: 0 additions & 1 deletion example/context/server-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export function createServerSession(): ServerSession {
};
}

// Register server session context serialization
registerContext<ServerSession>({
name: "serverSession",
context: serverSessionContext,
Expand Down
1 change: 0 additions & 1 deletion example/routes/features/_ignored/main.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
// This file is ignored for routing purposes because of the _ prefix in the directory name.
export const ignored = true;
2 changes: 0 additions & 2 deletions example/routes/features/data/server-deferred.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ export interface ServerDeferredLoaderData {
}

export function loader(): ServerDeferredLoaderData {
// This runs on the server - we can access server-only resources here
const timestamp = new Date().toISOString();

return {
fastData: `Server data loaded at ${timestamp}`,
// These promises will be serialized and streamed to the client
slowData: delay(1500).then(() => "Server data loaded after 1.5 seconds"),
verySlowData: delay(3000).then(
() => "Server data loaded after 3 seconds",
Expand Down
4 changes: 2 additions & 2 deletions example/routes/features/middleware/client-middleware.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function loader(
try {
navigationInfo = context.get(navigationInfoContext);
} catch {
// Context not set (SSR or initial load) - fall back to server loader
// ignore
}
if (!navigationInfo) {
return serverLoader() as Promise<LoaderData>;
Expand All @@ -60,7 +60,7 @@ export default function ClientMiddlewareExample({
try {
liveNavigationInfo = context.get(navigationInfoContext);
} catch {
// Context not set during SSR or initial load
// ignore
}

return (
Expand Down
4 changes: 1 addition & 3 deletions example/routes/features/middleware/combined.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {

const app = new Hono<AppEnv>();

// Server middleware - runs on every HTTP request
app.use(async (c, next) => {
const context = c.get("context");

Expand All @@ -31,7 +30,6 @@ app.use(async (c, next) => {

export default app;

// Server loader - called during SSR or when client calls serverLoader()
export function loader(
{ context }: RouteLoaderArgs,
): LoaderData {
Expand All @@ -40,7 +38,7 @@ export function loader(

return {
serverInfo,
clientInfo: null, // Not available on server
clientInfo: null,
loadedAt: new Date().toISOString(),
source: "server",
};
Expand Down
15 changes: 4 additions & 11 deletions example/routes/features/middleware/combined.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
RouteProps,
} from "@udibo/juniper";

// Contexts for sharing data
export interface ServerRequestInfo {
requestId: string;
serverTimestamp: string;
Expand All @@ -25,7 +24,6 @@ export const clientNavigationInfoContext = createContext<
ClientNavigationInfo
>();

// Client middleware - runs during client-side navigation
export const middleware: MiddlewareFunction[] = [
async ({ context }, next) => {
const clientInfo: ClientNavigationInfo = {
Expand All @@ -51,26 +49,22 @@ export interface LoaderData {
export function loader(
{ context, serverLoader }: RouteLoaderArgs<AnyParams, LoaderData>,
): LoaderData | Promise<LoaderData> {
// Check if we have client navigation info (means we're doing client-side navigation)
let clientInfo: ClientNavigationInfo | undefined;
try {
clientInfo = context.get(clientNavigationInfoContext);
} catch {
// Context not set yet (SSR or initial load)
// ignore
}

if (!clientInfo) {
// Initial SSR - call server loader
return serverLoader() as Promise<LoaderData>;
}

// Client-side navigation - we have client middleware context
// Also get server info if it was set during SSR
let serverInfo: ServerRequestInfo | undefined;
try {
serverInfo = context.get(serverRequestInfoContext);
} catch {
// Server context not available on client
// ignore
}

return {
Expand All @@ -87,18 +81,17 @@ export default function CombinedMiddlewareExample({
}: RouteProps<Record<string, string>, LoaderData>) {
const { serverInfo, clientInfo, loadedAt, source } = loaderData;

// Get live context values from props (may not be set during SSR/initial load)
let liveServerInfo: ServerRequestInfo | undefined;
let liveClientInfo: ClientNavigationInfo | undefined;
try {
liveServerInfo = context.get(serverRequestInfoContext);
} catch {
// Not set
// ignore
}
try {
liveClientInfo = context.get(clientNavigationInfoContext);
} catch {
// Not set
// ignore
}

return (
Expand Down
2 changes: 1 addition & 1 deletion src/_build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export async function getServerFlags(
return flags;
}
} catch {
// File doesn't exist or can't be read
// skip
}
return undefined;
}
Expand Down
2 changes: 0 additions & 2 deletions src/_client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ describe("App", () => {
it("should render with suppressHydrationWarning", () => {
render(<App>Test content</App>);
const html = document.documentElement;
// suppressHydrationWarning is a React internal prop and won't be visible in the DOM
// but we can verify the html element exists and has the expected structure
assertExists(html);
});

Expand Down
35 changes: 4 additions & 31 deletions src/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export function App({ children, htmlProps }: AppProps) {
}

const MAX_RELOAD_RETRIES = 2;
const RELOAD_WINDOW_MS = 30000; // 30 seconds
const RELOAD_WINDOW_MS = 30000;

interface ReloadState {
count: number;
Expand All @@ -189,10 +189,8 @@ function shouldReload(key: string): boolean {
const { count, timestamp } = getReloadState(key);
const now = Date.now();

// Reset counter if outside the time window
if (now - timestamp > RELOAD_WINDOW_MS) return true;

// Stop refreshing if we've exceeded max retries
return count < MAX_RELOAD_RETRIES;
}

Expand All @@ -201,13 +199,11 @@ function recordReload(key: string): void {
const now = Date.now();

if (now - timestamp > RELOAD_WINDOW_MS) {
// Start new window
sessionStorage.setItem(
key,
JSON.stringify({ count: 1, timestamp: now }),
);
} else {
// Increment within existing window
sessionStorage.setItem(
key,
JSON.stringify({ count: count + 1, timestamp }),
Expand All @@ -219,23 +215,14 @@ function clearReloadState(key: string): void {
try {
sessionStorage.removeItem(key);
} catch {
// Ignore errors (e.g., sessionStorage not available)
// ignore
}
}

const LAZY_LOAD_RELOAD_KEY = "__juniper_lazy_load_reload";
const SAME_LOCATION_RELOAD_KEY = "__juniper_same_location_reload";

/**
* A promise that never settles. Returned from a server-data fetch once a
* full-page navigation or reload has been kicked off (`location.assign` /
* `location.reload`), so React Router keeps the navigation in its *pending*
* state — the current page stays rendered — until the browser replaces the
* document. Resolving instead (even with `undefined`) lets the router commit
* the navigation and render the destination route for a frame first, which
* shows as a visible flash of the wrong page (e.g. an auth-gated page rendering
* unauthenticated for a moment before the redirect).
*/
// Never settles, so React Router stays pending during a full-page navigation/reload; resolving would render the destination route for a frame and flash the wrong page.
function holdForDocumentNavigation(): Promise<never> {
return new Promise<never>(() => {});
}
Expand All @@ -258,19 +245,15 @@ async function fetchServerData(

const contentType = response.headers.get("Content-Type");

// Handle streaming CBOR response (data with deferred promises)
if (contentType === "application/cbor-stream") {
if (!response.ok) {
// For errors, read the full stream and deserialize
const buffer = await response.arrayBuffer();
const deserialized = deserializeLoaderData(new Uint8Array(buffer));
throw deserializeError(deserialized as Record<string, unknown>);
}
// Stream the response - promises will be resolved as data arrives
return await deserializeStreamingLoaderData(response);
}

// Handle non-streaming CBOR response
if (contentType === "application/cbor") {
const buffer = await response.arrayBuffer();
const deserialized = deserializeLoaderData(new Uint8Array(buffer));
Expand All @@ -289,31 +272,22 @@ async function fetchServerData(
const currentUrl = new URL(globalThis.location.href);
const redirectUrl = new URL(location, currentUrl);

// `redirectDocument()` requests a full-page navigation (e.g. to a
// server-only route the client router can't render). Hand it to the
// browser instead of attempting a client-side route transition, which
// would 404 on the unmatched route or mis-handle its response.
// `redirectDocument()` needs a real browser navigation; a client-side transition would 404 on the server-only route.
if (redirectData.reloadDocument) {
delay(0).then(() => {
globalThis.location.assign(redirectUrl.href);
});
// Hold the router pending so the destination route never renders before
// the browser navigation lands — otherwise the page flashes.
return holdForDocumentNavigation();
}

// If redirecting to the same location, do a browser reload instead
if (redirectUrl.href === currentUrl.href) {
if (shouldReload(SAME_LOCATION_RELOAD_KEY)) {
recordReload(SAME_LOCATION_RELOAD_KEY);
delay(0).then(() => {
globalThis.location.reload();
});
// Hold pending while the reload runs (same rationale as above).
return holdForDocumentNavigation();
}
// Reload retries exhausted — resolve so the router can render rather than
// hang forever.
return undefined;
}

Expand Down Expand Up @@ -611,7 +585,6 @@ export function createLazyRoute(
}
throw error;
}
// Middleware can't be lazily loaded, so strip it from the result
const { middleware: _, ...rest } = createRoute(
routeFile,
serverFlags,
Expand Down
8 changes: 0 additions & 8 deletions src/_dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,9 @@ interface RebuildRequest {
client: boolean;
}

/**
* Regular expression to match valid route files
*/
const VALID_ROUTE_FILE_REGEX =
/^routes\/(?:(?!_)[^\/]+\/)*(?!_)[^\/]*(?<!\.test)\.tsx?$/;

/**
* Regular expression to match files that should NOT trigger rebuilds
*/
const SHOULD_IGNORE_REGEX =
/~|\.tmp$|\.lock$|\.log$|\.poll$|^(build|dev)\.ts$|^public\/build|\.test\./;

Expand Down Expand Up @@ -222,8 +216,6 @@ export class DevServer {
if (SHOULD_IGNORE_REGEX.test(posixPath)) {
return false;
}
// Check if the path is in the ignorePaths list
// Normalize to forward slashes for cross-platform comparison
const normalizedAbsPath = absolutePath.replace(/\\/g, "/");
for (const ignorePath of this.builder.ignorePaths) {
const normalizedIgnorePath = ignorePath.replace(/\\/g, "/");
Expand Down
Loading
Loading