perf(electron): add codemods to convert dynamic imports to static (#11690)

*  feat(electron): add codemods to convert dynamic imports to static

Add multiple modifiers for Electron build workflow:
- dynamicToStatic: Convert dynamicElement() to static imports
- nextDynamicToStatic: Convert next/dynamic (ssr: false) to static
- wrapChildrenWithClientOnly: Wrap layout children with ClientOnly + Loading fallback
- settingsContentToStatic: Handle SettingsContent componentMap pattern
- removeSuspense: Remove Suspense wrappers from components
- routes: Delete loading.tsx files and (mobile) directory

Also add fallback prop support to ClientOnly component for better UX during hydration.

*  feat(electron): enhance settingsContentToStatic with business features support

- Introduced a new function to check if business features are enabled via environment variables.
- Updated import generation functions to conditionally include business-related imports based on the new feature flag.
- Improved regex patterns for better matching of dynamic imports.
- Added logging to indicate when business features are active, enhancing debugging and user awareness.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-22 17:18:06 +08:00
committed by GitHub
parent 3a78f82618
commit ad32a61704
9 changed files with 882 additions and 4 deletions

View File

@@ -0,0 +1,273 @@
/* eslint-disable no-undef */
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
interface ImportInfo {
defaultImport?: string;
namedImports: string[];
}
interface DynamicElementInfo {
componentName: string;
end: number;
importPath: string;
isNamedExport: boolean;
namedExport?: string;
start: number;
}
const toPascalCase = (str: string): string => {
return str
.split(/[_-]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
};
const generateComponentName = (
importPath: string,
namedExport?: string,
existingNames: Set<string> = new Set(),
): string => {
if (namedExport) {
let name = namedExport;
let counter = 1;
while (existingNames.has(name)) {
name = `${namedExport}${counter++}`;
}
return name;
}
const segments = importPath
.split('/')
.filter((s) => s && !s.startsWith('.'))
.map((s) => s.replace(/^\((.+)\)$/, '$1').replace(/^\[(.+)]$/, '$1'));
const meaningfulSegments = segments.slice(-3).filter(Boolean);
let baseName =
meaningfulSegments.length > 0
? meaningfulSegments.map((s) => toPascalCase(s)).join('') + 'Page'
: 'Page';
let name = baseName;
let counter = 1;
while (existingNames.has(name)) {
name = `${baseName}${counter++}`;
}
return name;
};
const extractDynamicElements = (code: string): DynamicElementInfo[] => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const results: DynamicElementInfo[] = [];
const existingNames = new Set<string>();
const dynamicCalls = root.findAll({
rule: {
pattern: 'dynamicElement($IMPORT_FN, $DEBUG_ID)',
},
});
for (const call of dynamicCalls) {
const range = call.range();
const text = call.text();
const importMatch = text.match(/import\s*\(\s*["']([^"']+)["']\s*\)/);
invariant(
importMatch,
`[convertDynamicToStatic] Failed to extract import path from dynamicElement call: ${text.slice(0, 100)}`,
);
const importPath = importMatch![1];
const thenMatch = text.match(/\.then\s*\(\s*\(\s*(\w+)\s*\)\s*=>\s*\1\.(\w+)\s*\)/);
const namedExport = thenMatch ? thenMatch[2] : undefined;
const componentName = generateComponentName(importPath, namedExport, existingNames);
existingNames.add(componentName);
results.push({
componentName,
end: range.end.index,
importPath,
isNamedExport: !!namedExport,
namedExport,
start: range.start.index,
});
}
return results;
};
const buildImportMap = (elements: DynamicElementInfo[]): Map<string, ImportInfo> => {
const importMap = new Map<string, ImportInfo>();
for (const el of elements) {
const existing = importMap.get(el.importPath) || { namedImports: [] };
if (el.isNamedExport && el.namedExport) {
if (!existing.namedImports.includes(el.namedExport)) {
existing.namedImports.push(el.namedExport);
}
} else {
existing.defaultImport = el.componentName;
}
importMap.set(el.importPath, existing);
}
return importMap;
};
const generateImportStatements = (importMap: Map<string, ImportInfo>): string => {
const statements: string[] = [];
const sortedPaths = [...importMap.keys()].sort();
for (const importPath of sortedPaths) {
const info = importMap.get(importPath)!;
const parts: string[] = [];
if (info.defaultImport) {
parts.push(info.defaultImport);
}
if (info.namedImports.length > 0) {
parts.push(`{ ${info.namedImports.join(', ')} }`);
}
if (parts.length > 0) {
statements.push(`import ${parts.join(', ')} from '${importPath}';`);
}
}
return statements.join('\n');
};
const findImportInsertPosition = (code: string): number => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const imports = root.findAll({
rule: {
kind: 'import_statement',
},
});
invariant(imports.length > 0, '[convertDynamicToStatic] No import statements found in file');
const lastImport = imports.at(-1)!;
return lastImport.range().end.index;
};
const removeDynamicElementImport = (code: string): string => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const utilsRouterImport = root.find({
rule: {
kind: 'import_statement',
pattern: "import { $$$IMPORTS } from '@/utils/router'",
},
});
if (!utilsRouterImport) {
return code;
}
const importText = utilsRouterImport.text();
if (!importText.includes('dynamicElement')) {
return code;
}
const importSpecifiers = utilsRouterImport.findAll({
rule: {
kind: 'import_specifier',
},
});
const specifiersToKeep = importSpecifiers
.map((spec) => spec.text())
.filter((text) => !text.includes('dynamicElement'));
if (specifiersToKeep.length === 0) {
const range = utilsRouterImport.range();
let endIndex = range.end.index;
if (code[endIndex] === '\n') {
endIndex++;
}
return code.slice(0, range.start.index) + code.slice(endIndex);
}
const newImport = `import { ${specifiersToKeep.join(', ')} } from '@/utils/router';`;
const range = utilsRouterImport.range();
return code.slice(0, range.start.index) + newImport + code.slice(range.end.index);
};
export const convertDynamicToStatic = async (TEMP_DIR: string) => {
const routerConfigPath = path.join(
TEMP_DIR,
'src/app/[variants]/router/desktopRouter.config.tsx',
);
console.log(' Processing dynamicElement → static imports...');
await updateFile({
assertAfter: (code) => {
const noDynamicElement = !/dynamicElement\s*\(/.test(code);
const hasStaticImports = /^import .+ from ["']\.\.\/\(main\)/m.test(code);
return noDynamicElement && hasStaticImports;
},
filePath: routerConfigPath,
name: 'convertDynamicToStatic',
transformer: (code) => {
const elements = extractDynamicElements(code);
invariant(
elements.length > 0,
'[convertDynamicToStatic] No dynamicElement calls found in desktopRouter.config.tsx',
);
console.log(` Found ${elements.length} dynamicElement calls`);
const importMap = buildImportMap(elements);
const importStatements = generateImportStatements(importMap);
const edits: Array<{ end: number; start: number; text: string }> = [];
for (const el of elements) {
edits.push({
end: el.end,
start: el.start,
text: `<${el.componentName} />`,
});
}
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
const insertPos = findImportInsertPosition(result);
result = result.slice(0, insertPos) + '\n' + importStatements + result.slice(insertPos);
result = removeDynamicElementImport(result);
return result;
},
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('convertDynamicToStatic', convertDynamicToStatic, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/router/desktopRouter.config.tsx' },
]);
}

View File

@@ -3,14 +3,24 @@ import path from 'node:path';
import { modifyAppCode } from './appCode.mjs';
import { cleanUpCode } from './cleanUp.mjs';
import { convertDynamicToStatic } from './dynamicToStatic.mjs';
import { convertNextDynamicToStatic } from './nextDynamicToStatic.mjs';
import { modifyNextConfig } from './nextConfig.mjs';
import { removeSuspenseFromConversation } from './removeSuspense.mjs';
import { modifyRoutes } from './routes.mjs';
import { convertSettingsContentToStatic } from './settingsContentToStatic.mjs';
import { modifyStaticExport } from './staticExport.mjs';
import { isDirectRun, runStandalone } from './utils.mjs';
import { wrapChildrenWithClientOnly } from './wrapChildrenWithClientOnly.mjs';
export const modifySourceForElectron = async (TEMP_DIR: string) => {
await modifyNextConfig(TEMP_DIR);
await modifyAppCode(TEMP_DIR);
await wrapChildrenWithClientOnly(TEMP_DIR);
await convertDynamicToStatic(TEMP_DIR);
await convertNextDynamicToStatic(TEMP_DIR);
await convertSettingsContentToStatic(TEMP_DIR);
await removeSuspenseFromConversation(TEMP_DIR);
await modifyRoutes(TEMP_DIR);
await modifyStaticExport(TEMP_DIR);
await cleanUpCode(TEMP_DIR);

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-undef */
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';

View File

@@ -0,0 +1,233 @@
/* eslint-disable no-undef */
import { Lang, parse } from '@ast-grep/napi';
import { glob } from 'glob';
import fs from 'fs-extra';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone } from './utils.mjs';
interface DynamicImportInfo {
componentName: string;
end: number;
importPath: string;
start: number;
}
const extractDynamicImports = (code: string): DynamicImportInfo[] => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const results: DynamicImportInfo[] = [];
const dynamicCalls = root.findAll({
rule: {
pattern: 'const $NAME = dynamic(() => import($PATH))',
},
});
for (const call of dynamicCalls) {
const range = call.range();
const text = call.text();
const nameMatch = text.match(/const\s+(\w+)\s*=/);
invariant(
nameMatch,
`[convertNextDynamicToStatic] Failed to extract component name from dynamic call: ${text.slice(0, 100)}`,
);
const importMatch = text.match(/import\s*\(\s*["']([^"']+)["']\s*\)/);
invariant(
importMatch,
`[convertNextDynamicToStatic] Failed to extract import path from dynamic call: ${text.slice(0, 100)}`,
);
results.push({
componentName: nameMatch![1],
end: range.end.index,
importPath: importMatch![1],
start: range.start.index,
});
}
const dynamicCallsWithOptions = root.findAll({
rule: {
pattern: 'const $NAME = dynamic(() => import($PATH), $OPTIONS)',
},
});
for (const call of dynamicCallsWithOptions) {
const range = call.range();
const text = call.text();
const nameMatch = text.match(/const\s+(\w+)\s*=/);
invariant(
nameMatch,
`[convertNextDynamicToStatic] Failed to extract component name from dynamic call: ${text.slice(0, 100)}`,
);
const importMatch = text.match(/import\s*\(\s*["']([^"']+)["']\s*\)/);
invariant(
importMatch,
`[convertNextDynamicToStatic] Failed to extract import path from dynamic call: ${text.slice(0, 100)}`,
);
const alreadyExists = results.some(
(r) => r.componentName === nameMatch![1] && r.importPath === importMatch![1],
);
if (alreadyExists) continue;
results.push({
componentName: nameMatch![1],
end: range.end.index,
importPath: importMatch![1],
start: range.start.index,
});
}
return results;
};
const generateImportStatements = (imports: DynamicImportInfo[]): string => {
const uniqueImports = new Map<string, string>();
for (const imp of imports) {
if (!uniqueImports.has(imp.importPath)) {
uniqueImports.set(imp.importPath, imp.componentName);
}
}
const sortedPaths = [...uniqueImports.keys()].sort();
return sortedPaths
.map((importPath) => {
const componentName = uniqueImports.get(importPath)!;
return `import ${componentName} from '${importPath}';`;
})
.join('\n');
};
const findImportInsertPosition = (code: string, filePath: string): number => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const imports = root.findAll({
rule: {
kind: 'import_statement',
},
});
invariant(
imports.length > 0,
`[convertNextDynamicToStatic] No import statements found in ${filePath}`,
);
const lastImport = imports.at(-1)!;
return lastImport.range().end.index;
};
const removeDynamicImport = (code: string): string => {
const patterns = [
/import dynamic from ["']@\/libs\/next\/dynamic["'];\n?/g,
/import dynamic from ["']next\/dynamic["'];\n?/g,
];
let result = code;
for (const pattern of patterns) {
result = result.replace(pattern, '');
}
return result;
};
const removeUnusedLoadingImport = (code: string): string => {
const codeWithoutImport = code.replaceAll(/import Loading from ["'][^"']+["'];?\n?/g, '');
if (!/\bLoading\b/.test(codeWithoutImport)) {
return code.replaceAll(/import Loading from ["'][^"']+["'];?\n?/g, '');
}
return code;
};
const transformFile = (code: string, filePath: string): string => {
const imports = extractDynamicImports(code);
if (imports.length === 0) {
return code;
}
const importStatements = generateImportStatements(imports);
const edits: Array<{ end: number; start: number; text: string }> = [];
for (const imp of imports) {
edits.push({
end: imp.end,
start: imp.start,
text: '',
});
}
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
let endIndex = edit.end;
while (result[endIndex] === '\n' || result[endIndex] === '\r') {
endIndex++;
}
result = result.slice(0, edit.start) + result.slice(endIndex);
}
const insertPos = findImportInsertPosition(result, filePath);
if (importStatements) {
result = result.slice(0, insertPos) + '\n' + importStatements + result.slice(insertPos);
}
result = removeDynamicImport(result);
result = removeUnusedLoadingImport(result);
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result;
};
export const convertNextDynamicToStatic = async (TEMP_DIR: string) => {
const appDirs = [
{ dir: path.join(TEMP_DIR, 'src/app/(variants)'), label: 'src/app/(variants)' },
{ dir: path.join(TEMP_DIR, 'src/app/[variants]'), label: 'src/app/[variants]' },
];
console.log(' Processing next/dynamic → static imports...');
let processedCount = 0;
for (const { dir, label } of appDirs) {
if (!(await fs.pathExists(dir))) {
continue;
}
const files = await glob('**/*.tsx', { cwd: dir });
for (const file of files) {
const filePath = path.join(dir, file);
const code = await fs.readFile(filePath, 'utf8');
if (!code.includes('dynamic(')) {
continue;
}
const transformed = transformFile(code, `${label}/${file}`);
if (transformed !== code) {
await fs.writeFile(filePath, transformed);
processedCount++;
console.log(` Transformed: ${label}/${file}`);
}
}
}
console.log(` Processed ${processedCount} files with dynamic imports`);
};
if (isDirectRun(import.meta.url)) {
await runStandalone('convertNextDynamicToStatic', convertNextDynamicToStatic, []);
}

View File

@@ -0,0 +1,124 @@
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
const removeSuspenseWrapper = (code: string): string => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const suspenseElements = root.findAll({
rule: {
has: {
has: {
kind: 'identifier',
regex: '^Suspense$',
},
kind: 'jsx_opening_element',
},
kind: 'jsx_element',
},
});
if (suspenseElements.length === 0) {
return code;
}
const edits: Array<{ end: number; start: number; text: string }> = [];
for (const suspense of suspenseElements) {
const range = suspense.range();
const children = suspense.children();
let childrenText = '';
for (const child of children) {
const kind = child.kind();
if (kind === 'jsx_element' || kind === 'jsx_self_closing_element' || kind === 'jsx_fragment') {
childrenText = child.text();
break;
}
}
if (childrenText) {
edits.push({
end: range.end.index,
start: range.start.index,
text: childrenText,
});
}
}
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
};
const removeUnusedImports = (code: string): string => {
let result = code;
if (!result.includes('<Suspense')) {
result = result.replaceAll(/,?\s*Suspense\s*,?/g, (match) => {
if (match.startsWith(',') && match.endsWith(',')) {
return ',';
}
return '';
});
result = result.replaceAll(/{\s*,/g, '{');
result = result.replaceAll(/,\s*}/g, '}');
result = result.replaceAll(/{\s*}/g, '');
result = result.replaceAll(/import\s+{\s*}\s+from\s+["'][^"']+["'];\n?/g, '');
}
if (!result.includes('<Loading') && !result.includes('Loading />')) {
result = result.replaceAll(
/import Loading from ["']@\/components\/Loading\/BrandTextLoading["'];\n?/g,
'',
);
}
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result;
};
export const removeSuspenseFromConversation = async (TEMP_DIR: string) => {
const filePath = path.join(
TEMP_DIR,
'src/app/[variants]/(main)/agent/features/Conversation/index.tsx',
);
console.log(' Removing Suspense from Conversation/index.tsx...');
await updateFile({
assertAfter: (code) => {
const noSuspenseElement = !/<Suspense/.test(code);
return noSuspenseElement;
},
filePath,
name: 'removeSuspenseFromConversation',
transformer: (code) => {
invariant(
/<Suspense/.test(code),
'[removeSuspenseFromConversation] No Suspense element found in Conversation/index.tsx',
);
let result = removeSuspenseWrapper(code);
result = removeUnusedImports(result);
return result;
},
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('removeSuspenseFromConversation', removeSuspenseFromConversation, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/(main)/agent/features/Conversation/index.tsx' },
]);
}

View File

@@ -17,6 +17,7 @@ export const modifyRoutes = async (TEMP_DIR: string) => {
// Auth & User routes
'src/app/[variants]/(auth)',
'src/app/[variants]/(mobile)',
'src/app/[variants]/(main)/(mobile)/me',
'src/app/[variants]/(main)/changelog',
'src/app/[variants]/oauth',
@@ -45,13 +46,25 @@ export const modifyRoutes = async (TEMP_DIR: string) => {
});
}
// 2. Modify desktopRouter.config.tsx
// 2. Delete root loading.tsx files(not needed in Electron SPA)
const loadingFiles = ['src/app/loading.tsx', 'src/app/[variants]/loading.tsx'];
console.log(` Removing ${loadingFiles.length} root loading.tsx files...`);
for (const file of loadingFiles) {
const fullPath = path.join(TEMP_DIR, file);
await removePathEnsuring({
name: `modifyRoutes:delete:loading:${file}`,
path: fullPath,
});
}
// 3. Modify desktopRouter.config.tsx
const routerConfigPath = path.join(
TEMP_DIR,
'src/app/[variants]/router/desktopRouter.config.tsx',
);
console.log(' Processing src/app/[variants]/router/desktopRouter.config.tsx...');
await updateFile({
assertAfter: (code) => !/\bchangelog\b/.test(code),
filePath: routerConfigPath,
name: 'modifyRoutes:desktopRouterConfig',
transformer: (code) => {
@@ -86,7 +99,6 @@ export const modifyRoutes = async (TEMP_DIR: string) => {
return root.text();
},
assertAfter: (code) => !/\bchangelog\b/.test(code),
});
};

View File

@@ -0,0 +1,148 @@
/* eslint-disable no-undef */
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
interface DynamicImportInfo {
componentName: string;
end: number;
importPath: string;
key: string;
start: number;
}
const isBusinessFeaturesEnabled = () => {
const raw = process.env.ENABLE_BUSINESS_FEATURES;
if (!raw) return false;
const normalized = raw.trim().toLowerCase();
return normalized === 'true' || normalized === '1';
};
const extractDynamicImportsFromMap = (code: string): DynamicImportInfo[] => {
const results: DynamicImportInfo[] = [];
const regex = /\[SettingsTabs\.(\w+)]:\s*dynamic\(\s*\(\)\s*=>\s*import\(\s*["']([^"']+)["']\s*\)/g;
let match;
while ((match = regex.exec(code)) !== null) {
const key = match[1];
const importPath = match[2];
const componentName = key.charAt(0).toUpperCase() + key.slice(1) + 'Tab';
results.push({
componentName,
end: 0,
importPath,
key,
start: 0,
});
}
return results;
};
const generateStaticImports = (imports: DynamicImportInfo[], keepBusinessTabs: boolean): string => {
return imports
.filter((imp) => keepBusinessTabs || !imp.importPath.includes('@/business/'))
.map((imp) => `import ${imp.componentName} from '${imp.importPath}';`)
.join('\n');
};
const generateStaticComponentMap = (
imports: DynamicImportInfo[],
keepBusinessTabs: boolean,
): string => {
const entries = imports
.filter((imp) => keepBusinessTabs || !imp.importPath.includes('@/business/'))
.map((imp) => ` [SettingsTabs.${imp.key}]: ${imp.componentName},`);
return `const componentMap: Record<string, React.ComponentType<{ mobile?: boolean }>> = {\n${entries.join('\n')}\n}`;
};
export const convertSettingsContentToStatic = async (TEMP_DIR: string) => {
const filePath = path.join(
TEMP_DIR,
'src/app/[variants]/(main)/settings/features/SettingsContent.tsx',
);
console.log(' Processing SettingsContent.tsx dynamic imports...');
await updateFile({
assertAfter: (code) => {
const noDynamic = !/dynamic\(\s*\(\)\s*=>\s*import/.test(code);
const hasStaticMap = /componentMap:\s*Record<string,/.test(code);
return noDynamic && hasStaticMap;
},
filePath,
name: 'convertSettingsContentToStatic',
transformer: (code) => {
const keepBusinessTabs = isBusinessFeaturesEnabled();
if (keepBusinessTabs) {
console.log(
' ENABLE_BUSINESS_FEATURES is enabled, preserving business Settings tabs in componentMap',
);
}
const imports = extractDynamicImportsFromMap(code);
invariant(
imports.length > 0,
'[convertSettingsContentToStatic] No dynamic imports found in SettingsContent.tsx',
);
console.log(` Found ${imports.length} dynamic imports in componentMap`);
const staticImports = generateStaticImports(imports, keepBusinessTabs);
const staticComponentMap = generateStaticComponentMap(imports, keepBusinessTabs);
let result = code;
result = result.replace(
/import dynamic from ["']@\/libs\/next\/dynamic["'];\n?/,
'',
);
result = result.replace(
/import Loading from ["']@\/components\/Loading\/BrandTextLoading["'];\n?/,
'',
);
const ast = parse(Lang.Tsx, result);
const root = ast.root();
const lastImport = root.findAll({
rule: {
kind: 'import_statement',
},
}).at(-1);
invariant(
lastImport,
'[convertSettingsContentToStatic] No import statements found in SettingsContent.tsx',
);
const insertPos = lastImport!.range().end.index;
result = result.slice(0, insertPos) + '\nimport type React from \'react\';\n' + staticImports + result.slice(insertPos);
const componentMapRegex = /const componentMap = {[\S\s]*?\n};/;
invariant(
componentMapRegex.test(result),
'[convertSettingsContentToStatic] componentMap declaration not found in SettingsContent.tsx',
);
result = result.replace(componentMapRegex, staticComponentMap + ';');
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result;
},
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('convertSettingsContentToStatic', convertSettingsContentToStatic, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/(main)/settings/features/SettingsContent.tsx' },
]);
}

View File

@@ -0,0 +1,73 @@
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
export const wrapChildrenWithClientOnly = async (TEMP_DIR: string) => {
const layoutPath = path.join(TEMP_DIR, 'src/app/[variants]/layout.tsx');
console.log(' Wrapping children with ClientOnly in layout.tsx...');
await updateFile({
assertAfter: (code) => {
const hasClientOnlyImport = /import ClientOnly from ["']@\/components\/client\/ClientOnly["']/.test(code);
const hasLoadingImport = /import Loading from ["']@\/components\/Loading\/BrandTextLoading["']/.test(code);
const hasClientOnlyWrapper = /<ClientOnly fallback={<Loading/.test(code);
return hasClientOnlyImport && hasLoadingImport && hasClientOnlyWrapper;
},
filePath: layoutPath,
name: 'wrapChildrenWithClientOnly',
transformer: (code) => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
let result = code;
const hasClientOnlyImport = /import ClientOnly from ["']@\/components\/client\/ClientOnly["']/.test(code);
const hasLoadingImport = /import Loading from ["']@\/components\/Loading\/BrandTextLoading["']/.test(code);
const lastImport = root.findAll({
rule: {
kind: 'import_statement',
},
}).at(-1);
invariant(lastImport, '[wrapChildrenWithClientOnly] No import statements found in layout.tsx');
const insertPos = lastImport!.range().end.index;
let importsToAdd = '';
if (!hasClientOnlyImport) {
importsToAdd += "\nimport ClientOnly from '@/components/client/ClientOnly';";
}
if (!hasLoadingImport) {
importsToAdd += "\nimport Loading from '@/components/Loading/BrandTextLoading';";
}
if (importsToAdd) {
result = result.slice(0, insertPos) + importsToAdd + result.slice(insertPos);
}
const authProviderPattern = /<AuthProvider>\s*{children}\s*<\/AuthProvider>/;
invariant(
authProviderPattern.test(result),
'[wrapChildrenWithClientOnly] Pattern <AuthProvider>{children}</AuthProvider> not found in layout.tsx',
);
result = result.replace(
authProviderPattern,
`<AuthProvider>
<ClientOnly fallback={<Loading />}>{children}</ClientOnly>
</AuthProvider>`,
);
return result;
},
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('wrapChildrenWithClientOnly', wrapChildrenWithClientOnly, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/layout.tsx' },
]);
}

View File

@@ -1,15 +1,19 @@
'use client';
import type React from 'react';
import { type FC, type PropsWithChildren, useEffect, useState } from 'react';
const ClientOnly: FC<PropsWithChildren> = ({ children }) => {
const ClientOnly: FC<PropsWithChildren<{ fallback?: React.ReactNode }>> = ({
children,
fallback = null,
}) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
if (!mounted) return fallback;
return children;
};