diff --git a/openless-all/app/scripts/local-asr-polling-contract.test.mjs b/openless-all/app/scripts/local-asr-polling-contract.test.mjs index 91bbc3df..94f0c813 100644 --- a/openless-all/app/scripts/local-asr-polling-contract.test.mjs +++ b/openless-all/app/scripts/local-asr-polling-contract.test.mjs @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +import ts from 'typescript'; const source = await readFile( new URL('../src/pages/LocalAsr/index.tsx', import.meta.url), @@ -9,6 +10,58 @@ const refreshPolling = source.match( /window\.setInterval\(\(\) => \{\s*void refresh\(\)\s*\}, 3000\)/g, ) ?? []; +const sourceFile = ts.createSourceFile( + 'LocalAsr/index.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +const localAsr = sourceFile.statements.find( + (statement) => + ts.isFunctionDeclaration(statement) && statement.name?.text === 'LocalAsr', +); +if (!localAsr?.body) { + throw new Error('LocalAsr component declaration is missing'); +} + +const jsxComponentNames = new Set(); +const collectJsxComponentNames = (node) => { + if ( + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + ts.isIdentifier(node.tagName) + ) { + jsxComponentNames.add(node.tagName.text); + } + ts.forEachChild(node, collectJsxComponentNames); +}; +collectJsxComponentNames(localAsr.body); + +const createsComponentType = (node) => + ts.isArrowFunction(node) || + ts.isFunctionExpression(node) || + (ts.isConditionalExpression(node) && + (createsComponentType(node.whenTrue) || createsComponentType(node.whenFalse))); + +const nestedJsxComponents = localAsr.body.statements.flatMap((statement) => { + if (!ts.isVariableStatement(statement)) return []; + return statement.declarationList.declarations + .filter( + (declaration) => + ts.isIdentifier(declaration.name) && + jsxComponentNames.has(declaration.name.text) && + declaration.initializer && + createsComponentType(declaration.initializer), + ) + .map((declaration) => declaration.name.text); +}); + +if (nestedJsxComponents.length > 0) { + throw new Error( + `LocalAsr must not create JSX component types during render; found ${nestedJsxComponents.join(', ')}`, + ); +} + if (refreshPolling.length !== 1) { throw new Error(`LocalAsr should have one refresh poller, found ${refreshPolling.length}`); } @@ -29,4 +82,6 @@ for (const contract of [ } } -console.log('LocalAsr keeps one refresh poller and pauses it for the download dialog'); +console.log( + 'LocalAsr keeps one refresh poller, pauses it for the download dialog, and preserves stable JSX component types', +); diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index b5d21b16..19392d50 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -155,6 +155,48 @@ interface LocalAsrProps { embedded?: boolean } +interface LocalAsrContentWrapperProps { + embedded: boolean + children: ReactNode +} + +// 必须保持为模块级组件:如果在 LocalAsr 渲染函数内定义,3 秒刷新引发的任意 +// setState 都会创建新的组件类型,React 会重挂整棵子树并清空 Collapsible / +// SelectLite 等子组件的交互状态。 +function LocalAsrContentWrapper({ + embedded, + children, +}: LocalAsrContentWrapperProps) { + if (embedded) return <>{children}> + return ( +