diff --git a/client/client.js b/client/client.js index d614db0..6340e93 100644 --- a/client/client.js +++ b/client/client.js @@ -10,9 +10,11 @@ window.__ModuleLoader__.load({ // the bundle must bind React itself - otherwise every mobile component // crashes at render time with "ReferenceError: React is not defined". var React = require("react"); +var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name2 in all) @@ -26,6 +28,14 @@ var __copyProps = (to, from, except, desc) => { } return to; }; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // client/index.jsx @@ -109,7 +119,66 @@ function redactStatus(s) { } // client/mobile/MobileNavToggle.tsx -var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); +var primitives = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); + +// client/mobile/icon-compat.mjs +var MOBILE_ICON_CANDIDATES = { + panelLeft: [ + "IconPanelLeftOutlineRegular", + "IconPanelLeftOutlineMedium", + "IconPanelLeftOutline16", + "IconPanelLeftOutline" + ], + download: [ + "IconDownloadOutlineRegular", + "IconDownloadOutlineMedium", + "IconDownloadOutline16", + "IconDownloadOutline" + ], + folderOpen: [ + "IconFolderOpenOutlineRegular", + "IconFolderOpenOutlineMedium", + "IconFolderOpenOutline16", + "IconFolderOpenOutline", + "IconFolderOpen" + ] +}; +var MOBILE_ICON_FALLBACK = { + panelLeft: "\u2630", + download: "\u2913", + folderOpen: "\u25A4" +}; +function isRenderableIcon(value) { + if (typeof value === "function") return true; + return typeof value === "object" && value !== null; +} +function pickIcon(registry, names) { + if (registry === null || typeof registry !== "object") return null; + for (const name2 of names) { + const candidate = registry[name2]; + if (isRenderableIcon(candidate)) return candidate; + } + return null; +} +function resolveMobileIcons(registry) { + const resolved = {}; + for (const [key, names] of Object.entries(MOBILE_ICON_CANDIDATES)) { + resolved[key] = pickIcon(registry, names); + } + return resolved; +} + +// client/mobile/MobileIcon.tsx +function MobileIcon({ icon, name: name2, size }) { + if (!isRenderableIcon(icon)) { + return /* @__PURE__ */ React.createElement("span", { "data-mobile-nav": "icon-fallback", "aria-hidden": "true", style: { fontSize: size, lineHeight: 1 } }, MOBILE_ICON_FALLBACK[name2]); + } + const Icon = icon; + return /* @__PURE__ */ React.createElement(Icon, { size }); +} + +// client/mobile/MobileNavToggle.tsx +var icons = resolveMobileIcons(primitives); function MobileNavToggle({ toggleSidebar, t }) { const toggleExplorer = () => { const frame = document.querySelector('[data-mobile-nav="frame"]'); @@ -129,7 +198,7 @@ function MobileNavToggle({ toggleSidebar, t }) { title: t("open"), onClick: () => toggleSidebar() }, - /* @__PURE__ */ React.createElement(import_dsh_client_ui_primitives.IconPanelLeftOutline16, { size: 16 }) + /* @__PURE__ */ React.createElement(MobileIcon, { icon: icons.panelLeft, name: "panelLeft", size: 16 }) ), /* @__PURE__ */ React.createElement( "button", { @@ -139,13 +208,13 @@ function MobileNavToggle({ toggleSidebar, t }) { title: t("files"), onClick: toggleExplorer }, - /* @__PURE__ */ React.createElement(import_dsh_client_ui_primitives.IconFolderOpenOutline16, { size: 16 }) + /* @__PURE__ */ React.createElement(MobileIcon, { icon: icons.folderOpen, name: "folderOpen", size: 16 }) )); } // client/mobile/MobileNavOverlay.tsx var import_react = require("react"); -var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); +var primitives2 = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); // client/mobile/nav-targets.mjs var DRAWER_SELECTOR = '[data-mobile-nav="frame"] > :first-child'; @@ -181,6 +250,7 @@ function isOverlayTap(target) { } // client/mobile/MobileNavOverlay.tsx +var icons2 = resolveMobileIcons(primitives2); var MOBILE_QUERY = "(max-width: 1023px)"; function useMobile() { const [mobile, setMobile] = (0, import_react.useState)(() => window.matchMedia(MOBILE_QUERY).matches); @@ -367,12 +437,13 @@ function MobileNavOverlay({ toggleSidebar, t }) { title: t("open"), onClick: () => toggleSidebar() }, - /* @__PURE__ */ React.createElement(import_dsh_client_ui_primitives2.IconPanelLeftOutline16, { size: 18 }) + /* @__PURE__ */ React.createElement(MobileIcon, { icon: icons2.panelLeft, name: "panelLeft", size: 18 }) )); } // client/mobile/MobileDrawerFooter.tsx -var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); +var primitives3 = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); +var icons3 = resolveMobileIcons(primitives3); function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t }) { const sessionId = useSessions((state) => state.current); const openExplorer = () => { @@ -388,7 +459,7 @@ function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t title: t("files"), onClick: openExplorer }, - /* @__PURE__ */ React.createElement(import_dsh_client_ui_primitives3.IconPanelLeftOutline16, { size: 14 }), + /* @__PURE__ */ React.createElement(MobileIcon, { icon: icons3.panelLeft, name: "panelLeft", size: 14 }), /* @__PURE__ */ React.createElement("span", null, t("files")) ), /* @__PURE__ */ React.createElement( "button", @@ -402,7 +473,7 @@ function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t if (sessionId !== void 0) downloadSessionLog(sessionId); } }, - /* @__PURE__ */ React.createElement(import_dsh_client_ui_primitives3.IconDownloadOutline16, { size: 14 }), + /* @__PURE__ */ React.createElement(MobileIcon, { icon: icons3.download, name: "download", size: 14 }), /* @__PURE__ */ React.createElement("span", null, t("sessionLog")) )); } diff --git a/client/mobile/MobileDrawerFooter.tsx b/client/mobile/MobileDrawerFooter.tsx index 7a227f1..d430d07 100644 --- a/client/mobile/MobileDrawerFooter.tsx +++ b/client/mobile/MobileDrawerFooter.tsx @@ -1,6 +1,11 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconDownloadOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' import { NS } from './locales.ts' +import { MobileIcon } from './MobileIcon.tsx' +import { resolveMobileIcons } from './icon-compat.mjs' + +/** 宿主图标在加载时解析一次:不同 DSH 版本的导出名不同(见 icon-compat.mjs)。 */ +const icons = resolveMobileIcons(primitives) /** Full props for the sidebar footer action entry. */ export interface MobileDrawerFooterProps extends PropsRuntime<'sidebar.footer.action'>, PropsLocale { @@ -35,7 +40,7 @@ export function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSide title={t('files')} onClick={openExplorer} > - + {t('files')} diff --git a/client/mobile/MobileIcon.tsx b/client/mobile/MobileIcon.tsx new file mode 100644 index 0000000..b67dc39 --- /dev/null +++ b/client/mobile/MobileIcon.tsx @@ -0,0 +1,31 @@ +import { MOBILE_ICON_FALLBACK, isRenderableIcon } from './icon-compat.mjs' + +/** Props for the version-tolerant icon slot. */ +export interface MobileIconProps { + /** Icon component resolved from the host primitives namespace (null when absent). */ + icon: unknown + /** Which fallback glyph to draw when the running DSH has no such export. */ + name: 'panelLeft' | 'download' | 'folderOpen' + /** Square edge in px, forwarded to the host icon. */ + size: number +} + +/** + * Renders the resolved host icon, or a text glyph when this DSH build no longer + * exports that name. It must never hand `undefined` to createElement: that is + * exactly what crashed the whole slot entry (React #130) and left the mobile + * drawer dead on DSH 0.1.7-rc.2+ (see icon-compat.mjs for the version matrix). + */ +export function MobileIcon({ icon, name, size }: MobileIconProps) { + if (!isRenderableIcon(icon)) { + return ( + + ) + } + // The component type differs per DSH version, so it is only known at runtime; + // `never` as the return type keeps JSX typing happy without an `any` cast. + const Icon = icon as (props: { size?: number }) => never + return +} diff --git a/client/mobile/MobileNavOverlay.tsx b/client/mobile/MobileNavOverlay.tsx index b28093a..ddbbfd5 100644 --- a/client/mobile/MobileNavOverlay.tsx +++ b/client/mobile/MobileNavOverlay.tsx @@ -1,7 +1,12 @@ import { useEffect, useLayoutEffect, useState } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' import { NS } from './locales.ts' +import { MobileIcon } from './MobileIcon.tsx' +import { resolveMobileIcons } from './icon-compat.mjs' + +/** 宿主图标在加载时解析一次:不同 DSH 版本的导出名不同(见 icon-compat.mjs)。 */ +const icons = resolveMobileIcons(primitives) import { DRAWER_SELECTOR, TOGGLE_SELECTOR, @@ -282,7 +287,7 @@ export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) { title={t('open')} onClick={() => toggleSidebar()} > - + )} diff --git a/client/mobile/MobileNavToggle.tsx b/client/mobile/MobileNavToggle.tsx index d2ae1a6..7c2ae12 100644 --- a/client/mobile/MobileNavToggle.tsx +++ b/client/mobile/MobileNavToggle.tsx @@ -1,6 +1,11 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconFolderOpenOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' import { NS } from './locales.ts' +import { MobileIcon } from './MobileIcon.tsx' +import { resolveMobileIcons } from './icon-compat.mjs' + +/** 宿主图标在加载时解析一次:不同 DSH 版本的导出名不同(见 icon-compat.mjs)。 */ +const icons = resolveMobileIcons(primitives) /** Full props for the session-header directory toggle. */ export interface MobileNavToggleProps extends PropsRuntime<'conversation.session.header.actions'>, PropsLocale { @@ -36,7 +41,7 @@ export function MobileNavToggle({ toggleSidebar, t }: MobileNavToggleProps) { title={t('open')} onClick={() => toggleSidebar()} > - + ) diff --git a/client/mobile/icon-compat.mjs b/client/mobile/icon-compat.mjs new file mode 100644 index 0000000..bb35d85 --- /dev/null +++ b/client/mobile/icon-compat.mjs @@ -0,0 +1,90 @@ +// DSH primitives 图标导出的跨版本兼容层。 +// +// 背景(2026-09 实测,dsh 0.1.7-rc.2 + dsh-pocket 2.10.6): +// 移动端组件原来直接具名导入 +// IconPanelLeftOutline16 / IconDownloadOutline16 / IconFolderOpenOutline16, +// 而 @deepseek-ai/dsh-client-ui-primitives 在 0.1.7-rc 之后把图标导出改成了 +// IconOutlineRegular / IconOutlineMedium(`16` 尺寸后缀被移除)。 +// 于是这些导入在运行期变成 undefined —— 组件一渲染就抛 React #130 +// ("Element type is invalid ... got: undefined"),slot 条目整条崩掉: +// 移动样式表照样注入,但 `data-mobile-nav="frame"` 标记永远挂不上,抽屉彻底失效。 +// 手机上的表现:56px 侧栏图标条一直占位、正文被挤成一列(用户可见症状)。 +// +// 这里做三件事: +// 1. 不再按具体名字静态导入 —— 新版名字在旧版不存在、旧版名字在新版不存在; +// 对真正的 ESM 来说,具名导入不存在的导出是**链接期**错误,比 undefined 更难查; +// 2. 用候选名列表在运行时解析,先后覆盖新旧两代命名; +// 3. 全都解析不到时返回 null(绝不返回 undefined),调用方用文字字形兜底 —— +// 在任何一代 DSH 上都不会再把整条 slot 打崩。 +// +// 纯数据 + 纯函数、无 DOM 依赖,便于 `node --test` 直接引(test/icon-compat.test.js)。 + +/** 每个图标按「新名优先、旧名兜底」的候选顺序。 */ +export const MOBILE_ICON_CANDIDATES = { + panelLeft: [ + 'IconPanelLeftOutlineRegular', + 'IconPanelLeftOutlineMedium', + 'IconPanelLeftOutline16', + 'IconPanelLeftOutline', + ], + download: [ + 'IconDownloadOutlineRegular', + 'IconDownloadOutlineMedium', + 'IconDownloadOutline16', + 'IconDownloadOutline', + ], + folderOpen: [ + 'IconFolderOpenOutlineRegular', + 'IconFolderOpenOutlineMedium', + 'IconFolderOpenOutline16', + 'IconFolderOpenOutline', + 'IconFolderOpen', + ], +} + +/** 解析不到图标时用的文字字形(保证按钮仍然可见、可点、可读)。 */ +export const MOBILE_ICON_FALLBACK = { + panelLeft: '☰', + download: '⤓', + folderOpen: '▤', +} + +/** + * 注册表里的某个导出能不能当 React 组件渲染。 + * 组件可能是函数,也可能是对象(memo / forwardRef / class 组件),所以两者都收; + * 数字、字符串、null、undefined 一律不算 —— 它们正是打崩 slot 的元凶。 + * @param value - 待判定的导出值。 + * @returns 可渲染时为 true。 + */ +export function isRenderableIcon(value) { + if (typeof value === 'function') return true + return typeof value === 'object' && value !== null +} + +/** + * 按候选顺序取第一个可渲染的导出。 + * @param registry - 宿主 primitives 命名空间(可能为 null/不是对象)。 + * @param names - 候选导出名,按优先级排列。 + * @returns 命中的组件;一个都没有时返回 null(**绝不返回 undefined**)。 + */ +export function pickIcon(registry, names) { + if (registry === null || typeof registry !== 'object') return null + for (const name of names) { + const candidate = registry[name] + if (isRenderableIcon(candidate)) return candidate + } + return null +} + +/** + * 把宿主 primitives 命名空间解析成移动端要用的三个图标。 + * @param registry - 宿主 primitives 命名空间。 + * @returns `{ panelLeft, download, folderOpen }`,缺的为 null(不是 undefined)。 + */ +export function resolveMobileIcons(registry) { + const resolved = {} + for (const [key, names] of Object.entries(MOBILE_ICON_CANDIDATES)) { + resolved[key] = pickIcon(registry, names) + } + return resolved +} diff --git a/test/icon-compat.test.js b/test/icon-compat.test.js new file mode 100644 index 0000000..83eaaed --- /dev/null +++ b/test/icon-compat.test.js @@ -0,0 +1,120 @@ +// 图标导出的跨版本兼容层(client/mobile/icon-compat.mjs)。 +// +// 回归背景(dsh 0.1.7-rc.2 + dsh-pocket 2.10.6 实测):primitives 在 0.1.7-rc 之后把 +// Icon*Outline16 改成了 Icon*OutlineRegular / ...Medium,而移动端组件是**直接具名导入**, +// 于是运行期拿到 undefined → React #130("Element type is invalid ... got: undefined") +// → slot 条目整条崩掉:移动样式表还在注入,但 data-mobile-nav="frame" 永远挂不上, +// 抽屉彻底失效(手机上的表现是 56px 侧栏图标条一直占位、正文被挤成一列)。 +// +// 这里把两代命名、缺失兜底、以及「源码别再静态导入具体图标名」都钉成测试。 +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const { + MOBILE_ICON_CANDIDATES, + MOBILE_ICON_FALLBACK, + isRenderableIcon, + pickIcon, + resolveMobileIcons, +} = await import('../client/mobile/icon-compat.mjs') + +/** 造一个可用作图标的最小函数组件。 */ +const icon = (tag) => function IconStub() { + return tag +} + +/** 旧一代 DSH(≤ 0.1.6-alpha.x):导出带 16 尺寸后缀。 */ +const legacyRegistry = { + IconPanelLeftOutline16: icon('legacy-panel'), + IconDownloadOutline16: icon('legacy-download'), + IconFolderOpenOutline16: icon('legacy-folder'), +} + +/** 新一代 DSH(≥ 0.1.7-rc.x):只剩 Regular / Medium。 */ +const modernRegistry = { + IconPanelLeftOutlineRegular: icon('modern-panel'), + IconPanelLeftOutlineMedium: icon('modern-panel-medium'), + IconDownloadOutlineRegular: icon('modern-download'), + IconFolderOpenOutlineRegular: icon('modern-folder'), +} + +test('旧一代命名(...16)仍能解析出来 —— 不破坏旧 DSH', () => { + const resolved = resolveMobileIcons(legacyRegistry) + assert.equal(resolved.panelLeft, legacyRegistry.IconPanelLeftOutline16) + assert.equal(resolved.download, legacyRegistry.IconDownloadOutline16) + assert.equal(resolved.folderOpen, legacyRegistry.IconFolderOpenOutline16) +}) + +test('新一代命名(...Regular)能解析出来 —— 修好 0.1.7-rc 之后的 DSH', () => { + const resolved = resolveMobileIcons(modernRegistry) + assert.equal(resolved.panelLeft, modernRegistry.IconPanelLeftOutlineRegular) + assert.equal(resolved.download, modernRegistry.IconDownloadOutlineRegular) + assert.equal(resolved.folderOpen, modernRegistry.IconFolderOpenOutlineRegular) +}) + +test('两代命名同时存在时优先取新一代(Regular 先于 16)', () => { + const both = { ...legacyRegistry, ...modernRegistry } + const resolved = resolveMobileIcons(both) + assert.equal(resolved.panelLeft, modernRegistry.IconPanelLeftOutlineRegular) + // 候选顺序本身就是「新名优先」:Regular/Medium 排在 16 之前 + assert.ok( + MOBILE_ICON_CANDIDATES.panelLeft.indexOf('IconPanelLeftOutlineRegular') < + MOBILE_ICON_CANDIDATES.panelLeft.indexOf('IconPanelLeftOutline16'), + ) +}) + +test('一个都解析不到时返回 null —— 绝不返回 undefined(这正是打崩 slot 的原因)', () => { + const resolved = resolveMobileIcons({ IconPanelLeftGlyph: 1 }) + for (const key of Object.keys(MOBILE_ICON_CANDIDATES)) { + assert.equal(resolved[key], null, `${key} 应为 null`) + assert.notEqual(resolved[key], undefined, `${key} 绝不能是 undefined`) + } +}) + +test('非组件值一律不算图标(数字 / 字符串 / null / undefined)', () => { + assert.equal(pickIcon({ IconPanelLeftOutlineRegular: 42 }, ['IconPanelLeftOutlineRegular']), null) + assert.equal(pickIcon({ IconPanelLeftOutlineRegular: 'oops' }, ['IconPanelLeftOutlineRegular']), null) + assert.equal(pickIcon({ IconPanelLeftOutlineRegular: null }, ['IconPanelLeftOutlineRegular']), null) + assert.equal(pickIcon({}, ['IconPanelLeftOutlineRegular']), null) + // 组件形状:函数 / class / memo 之类的对象都算 + assert.equal(isRenderableIcon(icon('x')), true) + assert.equal(isRenderableIcon({ $$typeof: Symbol.for('react.memo') }), true) + assert.equal(isRenderableIcon(undefined), false) + assert.equal(isRenderableIcon(null), false) + assert.equal(isRenderableIcon(0), false) +}) + +test('宿主命名空间缺失 / 不是对象时不抛异常,只是全为 null', () => { + for (const registry of [null, undefined, 'nope', 7]) { + assert.doesNotThrow(() => resolveMobileIcons(registry)) + const resolved = resolveMobileIcons(registry) + for (const key of Object.keys(MOBILE_ICON_CANDIDATES)) assert.equal(resolved[key], null) + } +}) + +test('每个图标都有文字兜底字形(没有图标时按钮仍然可见可点)', () => { + for (const key of Object.keys(MOBILE_ICON_CANDIDATES)) { + assert.equal(typeof MOBILE_ICON_FALLBACK[key], 'string') + assert.ok(MOBILE_ICON_FALLBACK[key].length > 0, `${key} 需要兜底字形`) + } +}) + +test('移动端组件不再静态具名导入具体图标名(回归护栏)', () => { + const files = ['MobileNavToggle.tsx', 'MobileNavOverlay.tsx', 'MobileDrawerFooter.tsx'] + for (const file of files) { + const src = readFileSync(new URL(`../client/mobile/${file}`, import.meta.url), 'utf8') + // 不许再出现任何带版本后缀的具体图标名(新版/旧版都不行:换版本就崩) + assert.doesNotMatch(src, /Icon[A-Za-z]*(16|Regular|Medium)\b/, `${file} 不应静态引用具体图标名`) + // 必须走兼容层 + assert.match(src, /resolveMobileIcons/, `${file} 应通过 resolveMobileIcons 解析图标`) + assert.match(src, /MobileIcon/, `${file} 应通过 MobileIcon 渲染图标`) + } +}) + +test('打包产物已包含本次改动(改完 client/ 记得 node client/build.mjs)', () => { + const bundle = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8') + // 候选名与兜底标记都是字符串字面量,压缩后依然在 + assert.match(bundle, /IconPanelLeftOutlineRegular/, '产物里应有新一代候选名') + assert.match(bundle, /icon-fallback/, '产物里应有兜底字形标记') +})