From ad32a61704078add46f8fbbd2d5b8cb35d741ef5 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 22 Jan 2026 17:18:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20perf(electron):=20add=20codemods=20?= =?UTF-8?q?to=20convert=20dynamic=20imports=20to=20static=20(#11690)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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 --------- Signed-off-by: Innei --- .../modifiers/dynamicToStatic.mts | 273 ++++++++++++++++++ scripts/electronWorkflow/modifiers/index.mts | 10 + .../electronWorkflow/modifiers/nextConfig.mts | 1 + .../modifiers/nextDynamicToStatic.mts | 233 +++++++++++++++ .../modifiers/removeSuspense.mts | 124 ++++++++ scripts/electronWorkflow/modifiers/routes.mts | 16 +- .../modifiers/settingsContentToStatic.mts | 148 ++++++++++ .../modifiers/wrapChildrenWithClientOnly.mts | 73 +++++ src/components/client/ClientOnly.tsx | 8 +- 9 files changed, 882 insertions(+), 4 deletions(-) create mode 100644 scripts/electronWorkflow/modifiers/dynamicToStatic.mts create mode 100644 scripts/electronWorkflow/modifiers/nextDynamicToStatic.mts create mode 100644 scripts/electronWorkflow/modifiers/removeSuspense.mts create mode 100644 scripts/electronWorkflow/modifiers/settingsContentToStatic.mts create mode 100644 scripts/electronWorkflow/modifiers/wrapChildrenWithClientOnly.mts diff --git a/scripts/electronWorkflow/modifiers/dynamicToStatic.mts b/scripts/electronWorkflow/modifiers/dynamicToStatic.mts new file mode 100644 index 0000000000..531f46ef93 --- /dev/null +++ b/scripts/electronWorkflow/modifiers/dynamicToStatic.mts @@ -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 = 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(); + + 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 => { + const importMap = new Map(); + + 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 => { + 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' }, + ]); +} diff --git a/scripts/electronWorkflow/modifiers/index.mts b/scripts/electronWorkflow/modifiers/index.mts index 52d7de3884..0c86b3aacc 100644 --- a/scripts/electronWorkflow/modifiers/index.mts +++ b/scripts/electronWorkflow/modifiers/index.mts @@ -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); diff --git a/scripts/electronWorkflow/modifiers/nextConfig.mts b/scripts/electronWorkflow/modifiers/nextConfig.mts index 6190fffe30..feca53a5f5 100644 --- a/scripts/electronWorkflow/modifiers/nextConfig.mts +++ b/scripts/electronWorkflow/modifiers/nextConfig.mts @@ -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'; diff --git a/scripts/electronWorkflow/modifiers/nextDynamicToStatic.mts b/scripts/electronWorkflow/modifiers/nextDynamicToStatic.mts new file mode 100644 index 0000000000..a60b3da262 --- /dev/null +++ b/scripts/electronWorkflow/modifiers/nextDynamicToStatic.mts @@ -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(); + + 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, []); +} diff --git a/scripts/electronWorkflow/modifiers/removeSuspense.mts b/scripts/electronWorkflow/modifiers/removeSuspense.mts new file mode 100644 index 0000000000..a197b38cbf --- /dev/null +++ b/scripts/electronWorkflow/modifiers/removeSuspense.mts @@ -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(' { + 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('')) { + 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 = !/ { + invariant( + / { // 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), }); }; diff --git a/scripts/electronWorkflow/modifiers/settingsContentToStatic.mts b/scripts/electronWorkflow/modifiers/settingsContentToStatic.mts new file mode 100644 index 0000000000..3d867e6606 --- /dev/null +++ b/scripts/electronWorkflow/modifiers/settingsContentToStatic.mts @@ -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> = {\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 { + 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' }, + ]); +} diff --git a/scripts/electronWorkflow/modifiers/wrapChildrenWithClientOnly.mts b/scripts/electronWorkflow/modifiers/wrapChildrenWithClientOnly.mts new file mode 100644 index 0000000000..dadecaff9d --- /dev/null +++ b/scripts/electronWorkflow/modifiers/wrapChildrenWithClientOnly.mts @@ -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 = / { + 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 = /\s*{children}\s*<\/AuthProvider>/; + invariant( + authProviderPattern.test(result), + '[wrapChildrenWithClientOnly] Pattern {children} not found in layout.tsx', + ); + + result = result.replace( + authProviderPattern, + ` + }>{children} + `, + ); + + return result; + }, + }); +}; + +if (isDirectRun(import.meta.url)) { + await runStandalone('wrapChildrenWithClientOnly', wrapChildrenWithClientOnly, [ + { lang: Lang.Tsx, path: 'src/app/[variants]/layout.tsx' }, + ]); +} diff --git a/src/components/client/ClientOnly.tsx b/src/components/client/ClientOnly.tsx index f1025aded8..1fbc2a7c75 100644 --- a/src/components/client/ClientOnly.tsx +++ b/src/components/client/ClientOnly.tsx @@ -1,15 +1,19 @@ 'use client'; +import type React from 'react'; import { type FC, type PropsWithChildren, useEffect, useState } from 'react'; -const ClientOnly: FC = ({ children }) => { +const ClientOnly: FC> = ({ + children, + fallback = null, +}) => { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); - if (!mounted) return null; + if (!mounted) return fallback; return children; };