fix(plugins): restore registry group, own opaque material, hold refresh spinner

The install registry picker used MenuSurface and overrode the theme-owned
menu material tokens, which the shared-menu-material gate rejects. Return
the picker to a fieldset (its native group role) and paint an opaque
bg-layer-2 surface so the form controls stay readable without touching
menu tokens. This also restores the group's radios to the accessible tree.

Align plugin-install-registry.e2e and its snapshots with the PR's visible
text: registry option names drop the parentheses around the host, the
custom address field is no longer disabled, and the custom hint spells out
http(s) and ~/.npmrc.

Hold the manual-refresh spinner to a 400ms minimum so a fast read does not
flash it, clearing to idle (or the failure toast) only after the minimum,
and dropping a refresh that settles after disposal.

Prune the retired no-unknown-casts baseline entry for browser-plugin.client.spec.
This commit is contained in:
Yif committed 2026-09-27 09:46:15 +08:00
1 parent 9fabb67bca
commit eb9efc6265
9 files changed
+67 -35

No files matched your search

@@ -1,10 +1,10 @@
- group "从哪个 npm 源下载插件":
- radio "npm 官方源(registry.npmjs.org)"
- text: npm 官方源(registry.npmjs.org)
- radio "中国大陆镜像源(registry.npmmirror.com)" [checked]
- text: 中国大陆镜像源(registry.npmmirror.com)
- radio "npm 官方源 registry.npmjs.org"
- text: npm 官方源 registry.npmjs.org
- radio "中国大陆镜像源 registry.npmmirror.com" [checked]
- text: 中国大陆镜像源 registry.npmmirror.com
- radio "自定义地址"
- text: 自定义地址
- textbox "自定义地址" [disabled]:
- textbox "自定义地址":
- /placeholder: https://npm.example.com/
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
@@ -1,10 +1,10 @@
- group "从哪个 npm 源下载插件":
- radio "npm 官方源(registry.npmjs.org)" [checked]
- text: npm 官方源(registry.npmjs.org)
- radio "中国大陆镜像源(registry.npmmirror.com)"
- text: 中国大陆镜像源(registry.npmmirror.com)
- radio "npm 官方源 registry.npmjs.org" [checked]
- text: npm 官方源 registry.npmjs.org
- radio "中国大陆镜像源 registry.npmmirror.com"
- text: 中国大陆镜像源 registry.npmmirror.com
- radio "自定义地址"
- text: 自定义地址
- textbox "自定义地址" [disabled]:
- textbox "自定义地址":
- /placeholder: https://npm.example.com/
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
@@ -1,10 +1,10 @@
- group "从哪个 npm 源下载插件":
- radio "npm 官方源(registry.npmjs.org)"
- text: npm 官方源(registry.npmjs.org)
- radio "中国大陆镜像源(registry.npmmirror.com)" [checked]
- text: 中国大陆镜像源(registry.npmmirror.com)
- radio "npm 官方源 registry.npmjs.org"
- text: npm 官方源 registry.npmjs.org
- radio "中国大陆镜像源 registry.npmmirror.com" [checked]
- text: 中国大陆镜像源 registry.npmmirror.com
- radio "自定义地址"
- text: 自定义地址
- textbox "自定义地址" [disabled]:
- textbox "自定义地址":
- /placeholder: https://npm.example.com/
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
@@ -75,7 +75,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding
expect(await page.getByRole('radio').count()).toBe(0)
await registryToggle.click()
const options = page.getByRole('group', { name: '从哪个 npm 源下载插件', exact: true })
const mirror = options.getByRole('radio', { name: '中国大陆镜像源(registry.npmmirror.com)', exact: true })
const mirror = options.getByRole('radio', { name: '中国大陆镜像源 registry.npmmirror.com', exact: true })
await expect.poll(async () => (await readFile(requestLog, 'utf8')).trim().split('\n').filter(Boolean)).toHaveLength(2)
const pingUrls = (await readFile(requestLog, 'utf8')).trim().split('\n').sort()
expect(pingUrls).toEqual(['https://registry.npmjs.org/-/ping', 'https://registry.npmmirror.com/-/ping'].map(url => JSON.stringify(url)))
@@ -84,7 +84,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/fastest-default.expected.md', import.meta.url)),
await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode())
} else {
expect(await options.getByRole('radio', { name: 'npm 官方源(registry.npmjs.org)', exact: true }).isChecked()).toBe(true)
expect(await options.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true }).isChecked()).toBe(true)
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/official-default.expected.md', import.meta.url)),
await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode())
await mirror.click()
@@ -700,9 +700,9 @@
margin: 0;
padding: 8px;
border: 0;
/* Keep an opaque surface behind the registry form controls. */
--dsw-menu-surface-fill: var(--dsw-alias-bg-layer-2);
--dsw-menu-backdrop-filter: none;
/* An opaque layer keeps the registry form controls readable over the dialog. */
background: var(--dsw-alias-bg-layer-2);
border-radius: var(--dsw-radius-lg);
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
min-width: 0;
@@ -16,7 +16,7 @@ import {
Button, IconCheckCircleFillRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineMedium,
IconChevronRightOutlineRegular, IconCloseOutlineMedium,
IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular,
IconWarningOutlineRegular, Input, MenuSurface, Modal,
IconWarningOutlineRegular, Input, Modal,
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal,
StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer,
type IconProps, type StateDotState, type TerminalBlockLabels,
@@ -808,7 +808,7 @@ function InstallDialog({
// The registry options float over the dialog from their toggle, so unfolding them never adds to its height;
// the store folds them when a run starts, so they show at the spec only.
const registryToggleRef = useRef<HTMLButtonElement | null>(null)
const registryPanelRef = useRef<HTMLDivElement | null>(null)
const registryPanelRef = useRef<HTMLFieldSetElement | null>(null)
const registryCustomRef = useRef<HTMLInputElement | null>(null)
const registryShown = install.registryOpen && phase === 'idle'
const specComposition = useInstallComposition(install.open && phase === 'idle')
@@ -978,9 +978,8 @@ function InstallDialog({
: null}
{registryShown
? createPortal(
<MenuSurface
<fieldset
ref={registryPanelRef}
role="group"
id={registryId}
className={css.registry}
style={registryPosition ?? { visibility: 'hidden', left: 0, top: 0 }}
@@ -1038,7 +1037,7 @@ function InstallDialog({
: null}
<span className={css.registryHint}>{t('registryCustomHint')}</span>
</div>
</MenuSurface>,
</fieldset>,
document.body,
)
: null}
@@ -107,6 +107,9 @@ export type RegistryChoice =
/** The choice shown until the Host has said which registry it asks first: the one pnpm's own configuration names. */
const OFFICIAL_REGISTRY: RegistryChoice = { kind: 'offered', registry: null }
/** Hold the manual-refresh spinner at least this long so a fast read does not flash it. */
const REFRESH_SPINNER_MIN_MS = 400
/**
* The registry a choice asks, as the Host's install plan compares registries: pnpm's own configuration stands for
* the URL it names, once the Host has read it.
@@ -727,6 +730,7 @@ export class PluginManagerController {
/** Keep manual refresh feedback until its coalesced reads settle, without clearing cached cards. */
private async refresh(): Promise<void> {
if (this.disposed || this.getSnapshot().refreshStatus === 'refreshing') return
const startedAt = Date.now()
this.patch({
refreshStatus: 'refreshing',
...this.getSnapshot().notice?.kind === 'refresh-failed' ? { notice: null } : {},
@@ -737,10 +741,15 @@ export class PluginManagerController {
// A rejected transport request leaves the cached cards available for retry.
this.patch({ status: 'error' })
} finally {
const failed = this.getSnapshot().status === 'error'
this.patch(failed && this.hasCachedInventory
? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } }
: { refreshStatus: failed ? 'failed' : 'idle' })
// Hold the spinner to its minimum so a fast read does not flash it, then settle.
const remaining = REFRESH_SPINNER_MIN_MS - (Date.now() - startedAt)
if (remaining > 0) await new Promise<void>((resolve) => { setTimeout(resolve, remaining) })
if (!this.disposed) {
const failed = this.getSnapshot().status === 'error'
this.patch(failed && this.hasCachedInventory
? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } }
: { refreshStatus: failed ? 'failed' : 'idle' })
}
}
}
@@ -1232,6 +1232,33 @@ describe('PluginManagerController', () => {
await loading
expect(state()).toBe(before)
})
it('drops a manual refresh that settles after disposal', async () => {
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
const { plugins, face, state, controller } = bench()
await controller.load()
plugins.listBundles.mockReturnValueOnce(gate.promise)
face.refresh()
await vi.waitFor(() => { expect(state().refreshStatus).toBe('refreshing') })
controller.dispose()
const before = state()
gate.resolve(ok([{ ...BUNDLE, version: '9.9.9' }]))
await new Promise<void>((resolve) => { setTimeout(resolve, 10) })
expect(state()).toBe(before)
})
it('settles a manual refresh whose read outlasts the spinner minimum without an extra hold', async () => {
const { plugins, face, state, controller } = bench()
await controller.load()
plugins.listBundles.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => { setTimeout(resolve, 420) })
return ok([{ ...BUNDLE, version: '0.42.0' }])
})
face.refresh()
await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }, { timeout: 2_000 })
expect(state().packages[0]?.version).toBe('0.42.0')
expect(state().status).toBe('ready')
})
it('offers the registries the Host configured, starts from its first, and asks the check and the run to start where the person chose', async () => {
const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
const { plugins, face, state, controller, started } = bench({
-3
View File
@@ -619,9 +619,6 @@
"packages/client/ui-plan/tests/plan-resource.client.spec.ts": {
"fa53d5cd55d87b522c83a98968e3a2bf933b1cc1b84d2f009e79d6c702099786": 1
},
"packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx": {
"04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1
},
"packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx": {
"6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 1
},