From b25831b0edacfbc05dfce003f9959517571ea9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 14 May 2026 10:02:56 +0800 Subject: [PATCH] fix(deps,cli): add @types/react overrides + move refreshStatic out of setCurrentModel updater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the multi-round audit of the ink 7.0.3 re-upgrade: 1. @types/react / @types/react-dom now pinned to ^19.2.0 in root overrides. packages/web-templates still declares @types/react ^18.2.0 in its devDeps. Today the CLI build is unaffected (web-templates's 18.x types are nested in its own node_modules and the React-using src/insight and src/export-html files are excluded from its tsconfig build), but a future reincludes-or-hoist accident would land conflicting global JSX namespaces in the CLI compile graph. Match the dep dedup we already enforce for `react` and `react-dom` so the type graph stays as deduped as the runtime graph. 2. AppContainer's onModelChange handler was calling refreshStatic() as a side-effect inside the setCurrentModel updater. React.StrictMode double-invokes state updaters in dev, so model swaps fired two clearTerminal writes + two key bumps. The double work was masked under ink 6 (key changes were no-ops on ), but ink 7.0.3 honors key changes — the doubled work is now potentially visible as a faster flash-flash on every model switch. Refactor: setCurrentModel becomes a pure setter; refreshStatic moves into a useEffect keyed on currentModel with a ref-comparison guard so the first render doesn't fire. Single clearTerminal write per real model change, even under StrictMode. Verified: npm ls ink → single 7.0.3, npm ls react → single 19.2.4, npm ls @types/react → 19.2.10 hoisted (npm flags web-templates's 18.x constraint as overridden, which is the intended behavior). Typecheck clean across cli + core workspaces. --- package.json | 4 +++- packages/cli/src/ui/AppContainer.tsx | 24 +++++++++++++++--------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 3d824d902..c6c00140e 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,9 @@ "baseline-browser-mapping": "^2.9.19", "normalize-package-data": "^7.0.1", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0" }, "bin": { "qwen": "dist/cli.js" diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d6cbfd82e..6ad68705f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -591,19 +591,25 @@ export const AppContainer = (props: AppContainerProps) => { // Keep the static header in sync with model changes without polling. // Ink's output is append-only, so model changes must explicitly // clear and remount the static region to redraw the banner at the top. + // + // refreshStatic() is fired from an effect (not inside the setState updater) + // so React.StrictMode's double-invoke of state updaters doesn't translate + // into two clearTerminal writes per model change. Side-effects belong in + // effects; the updater stays pure. useEffect(() => { const unsubscribe = config.onModelChange((model) => { - setCurrentModel((prev) => { - if (prev === model) { - return prev; - } - refreshStatic(); - return model; - }); + setCurrentModel(model); }); - return unsubscribe; - }, [config, refreshStatic]); + }, [config]); + + const prevCurrentModelRef = useRef(currentModel); + useEffect(() => { + if (prevCurrentModelRef.current !== currentModel) { + prevCurrentModelRef.current = currentModel; + refreshStatic(); + } + }, [currentModel, refreshStatic]); const { isThemeDialogOpen,