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
6 changes: 4 additions & 2 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"packages/generator/sandpack-react/src/templates/**",
"tests/integration/swc/fixtures/minify-css/src/bootstrap.css",
"tests/integration/swc/fixtures/config-function/src/bootstrap.css",
"packages/runtime/plugin-runtime/static/**"
"packages/runtime/plugin-runtime/static/**",
"tests/integration/i18n/mf-consumer/@mf-types/**"
]
},
"css": {
Expand Down Expand Up @@ -113,7 +114,8 @@
"tests/integration/module/plugins/vue/**/*",
"packages/module/plugin-module-node-polyfill/src/globals.js",
"packages/runtime/plugin-runtime/static/**",
"packages/cli/flight-server-transform-plugin/tests/fixture/**/*"
"packages/cli/flight-server-transform-plugin/tests/fixture/**/*",
"**/@mf-types/**"
]
}
}
3 changes: 2 additions & 1 deletion packages/cli/builder/src/shared/parseCommonConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export async function parseCommonConfig(
html: { outputStructure, appIcon, ...htmlConfig } = {},
source: { alias, globalVars, transformImport, ...sourceConfig } = {},
dev = {},
server = {},
security: { checkSyntax, sri, ...securityConfig } = {},
tools: {
devServer,
Expand Down Expand Up @@ -188,7 +189,7 @@ export async function parseCommonConfig(
const { rsbuildDev, rsbuildServer } = transformToRsbuildServerOptions(
dev || {},
devServer || {},
builderConfig.server,
server || {},
);

rsbuildConfig.server = removeUndefinedKey(rsbuildServer);
Expand Down
24 changes: 19 additions & 5 deletions packages/runtime/plugin-i18n/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ export interface I18nPluginOptions {
localeDetection?: LocaleDetectionOptions;
backend?: BackendOptions;
transformRuntimeConfig?: TransformRuntimeConfigFn;
customPlugin?: {
runtime?: {
name?: string;
path?: string;
};
server?: {
name?: string;
};
};
[key: string]: any;
}

Expand All @@ -21,8 +30,13 @@ export const i18nPlugin = (
): CliPlugin<AppTools> => ({
name: '@modern-js/plugin-i18n',
setup: api => {
const { localeDetection, backend, transformRuntimeConfig, ...restOptions } =
options;
const {
localeDetection,
backend,
transformRuntimeConfig,
customPlugin,
...restOptions
} = options;
api._internalRuntimePlugins(({ entrypoint, plugins }) => {
const localeDetectionOptions = localeDetection
? getLocaleDetectionOptions(entrypoint.entryName, localeDetection)
Expand Down Expand Up @@ -50,8 +64,8 @@ export const i18nPlugin = (
};

plugins.push({
name: 'i18n',
path: `@${metaName}/plugin-i18n/runtime`,
name: customPlugin?.runtime?.name || 'i18n',
path: customPlugin?.runtime?.path || `@${metaName}/plugin-i18n/runtime`,
config,
});
return {
Expand Down Expand Up @@ -88,7 +102,7 @@ export const i18nPlugin = (
});

plugins.push({
name: `@${metaName}/plugin-i18n/server`,
name: customPlugin?.server?.name || `@${metaName}/plugin-i18n/server`,
options: {
localeDetection,
staticRoutePrefixes,
Expand Down
103 changes: 83 additions & 20 deletions packages/runtime/plugin-i18n/src/runtime/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface ModernI18nContextValue {
entryName?: string;
languages?: string[];
localePathRedirect?: boolean;
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
// Callback to update language in context
updateLanguage?: (newLang: string) => void;
}
Expand Down Expand Up @@ -41,6 +42,44 @@ export interface UseModernI18nReturn {
isLanguageSupported: (lang: string) => boolean;
}

/**
* Check if the given pathname should ignore automatic locale redirect
*/
const shouldIgnoreRedirect = (
pathname: string,
languages: string[],
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
): boolean => {
if (!ignoreRedirectRoutes) {
return false;
}

// Remove language prefix if present (e.g., /en/api -> /api)
const segments = pathname.split('/').filter(Boolean);
let pathWithoutLang = pathname;
if (segments.length > 0 && languages.includes(segments[0])) {
// Remove language prefix
pathWithoutLang = `/${segments.slice(1).join('/')}`;
}

// Normalize path (ensure it starts with /)
const normalizedPath = pathWithoutLang.startsWith('/')
? pathWithoutLang
: `/${pathWithoutLang}`;

if (typeof ignoreRedirectRoutes === 'function') {
return ignoreRedirectRoutes(normalizedPath);
}

// Check if pathname matches any of the ignore patterns
return ignoreRedirectRoutes.some(pattern => {
// Support both exact match and prefix match
return (
normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`)
);
});
};

// Safe hook wrapper to handle cases where router context is not available
const useRouterHooks = () => {
try {
Expand Down Expand Up @@ -91,6 +130,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
i18nInstance,
languages,
localePathRedirect,
ignoreRedirectRoutes,
updateLanguage,
} = context;

Expand Down Expand Up @@ -143,33 +183,55 @@ export const useModernI18n = (): UseModernI18nReturn => {
const entryPath = getEntryPath();
const relativePath = currentPath.replace(entryPath, '');

// Build new path with updated language
const newPath = buildLocalizedUrl(
relativePath,
newLang,
languages || [],
);
const newUrl = entryPath + newPath + location.search + location.hash;
// Check if this route should ignore automatic redirect
if (
!shouldIgnoreRedirect(
relativePath,
languages || [],
ignoreRedirectRoutes,
)
) {
// Build new path with updated language
const newPath = buildLocalizedUrl(
relativePath,
newLang,
languages || [],
);
const newUrl =
entryPath + newPath + location.search + location.hash;

// Navigate to new URL
navigate(newUrl, { replace: true });
// Navigate to new URL
await navigate(newUrl, { replace: true });
}
} else if (localePathRedirect && isBrowser() && !hasRouter) {
// Fallback: use window.history API when router is not available
const currentPath = window.location.pathname;
const entryPath = getEntryPath();
const relativePath = currentPath.replace(entryPath, '');

// Build new path with updated language
const newPath = buildLocalizedUrl(
relativePath,
newLang,
languages || [],
);
const newUrl =
entryPath + newPath + window.location.search + window.location.hash;

// Use history API to navigate without page reload
window.history.pushState(null, '', newUrl);
// Check if this route should ignore automatic redirect
if (
!shouldIgnoreRedirect(
relativePath,
languages || [],
ignoreRedirectRoutes,
)
) {
// Build new path with updated language
const newPath = buildLocalizedUrl(
relativePath,
newLang,
languages || [],
);
const newUrl =
entryPath +
newPath +
window.location.search +
window.location.hash;

// Use history API to navigate without page reload
window.history.pushState(null, '', newUrl);
}
}

// Update language state after URL update
Expand All @@ -185,6 +247,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
i18nInstance,
updateLanguage,
localePathRedirect,
ignoreRedirectRoutes,
languages,
hasRouter,
navigate,
Expand Down
3 changes: 3 additions & 0 deletions packages/runtime/plugin-i18n/src/runtime/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface I18nPluginOptions {
i18nInstance?: I18nInstance;
changeLanguage?: (lang: string) => void;
initOptions?: I18nInitOptions;
[key: string]: any;
}

const getPathname = (context: TRuntimeContext) => {
Expand All @@ -64,6 +65,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
languages = [],
fallbackLanguage = 'en',
detection,
ignoreRedirectRoutes,
} = localeDetection || {};
const { enabled: backendEnabled = false } = backend || {};
let I18nextProvider: React.FunctionComponent<any> | null;
Expand Down Expand Up @@ -184,6 +186,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
entryName,
languages,
localePathRedirect,
ignoreRedirectRoutes,
updateLanguage: setLang,
};

Expand Down
44 changes: 44 additions & 0 deletions packages/runtime/plugin-i18n/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,42 @@ const convertToHonoLanguageDetectorOptions = (
};
};

/**
* Check if the given pathname should ignore automatic locale redirect
*/
const shouldIgnoreRedirect = (
pathname: string,
urlPath: string,
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
): boolean => {
if (!ignoreRedirectRoutes) {
return false;
}

// Remove urlPath prefix to get remaining path for matching
const basePath = urlPath.replace('/*', '');
const remainingPath = pathname.startsWith(basePath)
? pathname.slice(basePath.length)
: pathname;

// Normalize path (ensure it starts with /)
const normalizedPath = remainingPath.startsWith('/')
? remainingPath
: `/${remainingPath}`;

if (typeof ignoreRedirectRoutes === 'function') {
return ignoreRedirectRoutes(normalizedPath);
}

// Check if pathname matches any of the ignore patterns
return ignoreRedirectRoutes.some(pattern => {
// Support both exact match and prefix match
return (
normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`)
);
});
};

/**
* Check if the given pathname is a static resource request
* This includes:
Expand Down Expand Up @@ -206,6 +242,7 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
languages = [],
fallbackLanguage = 'en',
detection,
ignoreRedirectRoutes,
} = getLocaleDetectionOptions(entryName, options.localeDetection);
const staticRoutePrefixes = options.staticRoutePrefixes;
const originUrlPath = route.urlPath;
Expand Down Expand Up @@ -262,6 +299,13 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
return await next();
}

// Check if this route should ignore automatic redirect
if (
shouldIgnoreRedirect(pathname, urlPath, ignoreRedirectRoutes)
) {
return await next();
}

const language = getLanguageFromPath(c.req, urlPath, languages);
if (!language) {
// Get detected language from languageDetector middleware
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/plugin-i18n/src/shared/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface BaseLocaleDetectionOptions {
languages?: string[];
fallbackLanguage?: string;
detection?: LanguageDetectorOptions;
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
}

export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
Expand Down
17 changes: 16 additions & 1 deletion packages/runtime/plugin-runtime/src/router/runtime/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,11 @@ export const routerPlugin = (
return match || '/';
};

// Cache router instance in closure to avoid recreating on parent re-render
let cachedRouter: any = null;

const RouterWrapper = (props: any) => {
const { router, routes } = useRouterCreation(
const routerResult = useRouterCreation(
{
...props,
rscPayload: props?.rscPayload,
Expand All @@ -162,6 +165,18 @@ export const routerPlugin = (
},
);

// Only cache router instance, routes are always from routerResult
// rscPayload is stable after first render, so we only create router once
const router = useMemo(() => {
if (cachedRouter) {
return cachedRouter;
}

cachedRouter = routerResult.router;
return cachedRouter;
}, []);
const { routes } = routerResult;

useEffect(() => {
routesContainer.current = routes;
}, [routes]);
Expand Down
1 change: 0 additions & 1 deletion packages/server/server/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ export const devPlugin = (

middlewares.push({
name: 'mock-dev',

handler: mockMiddleware,
});

Expand Down
5 changes: 0 additions & 5 deletions packages/toolkit/utils/src/universal/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@ export const ROUTE_MANIFEST = `_MODERNJS_ROUTE_MANIFEST`;
*/
export const ROUTE_MODULES = `_routeModules`;

/**
* hmr socket connect path
*/
export const HMR_SOCK_PATH = '/webpack-hmr';

/**
* html placeholder
*/
Expand Down
Loading