Pārlūkot izejas kodu

fix(permission): tick only on a real generation change

A notification repeating the published generation no longer withdraws displayed
options, so the invalidation observable and the README describe the same rule.
The composer seat hides on a cleared catalog while the slash picker keeps its
failure and retry, and the disposed guard that both callers already provide is
gone from the tick.
Chinesezjc 3 nedēļas atpakaļ
vecāks
revīzija
eb6373d711

+ 2 - 2
packages/client/ui-permission-presets/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-permission-presets/README.md
-README.md: 202d842a9b23574e3a2576811c9e2b4698b83e62
-README.zh.md: 6353a49f61790c6d45f6b481faf76dae9f5eba4d
+README.md: 190c907293bbe7a0c07fc255cc56338bed6bcf32
+README.zh.md: c6e3b9c4b9d5f036b15f9ae84c8acade3a62c183

+ 1 - 1
packages/client/ui-permission-presets/README.md

@@ -45,7 +45,7 @@ The row derives its options from the host's dynamic `defaultPreset` enum, uses t
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The General row reads the explicitly exposed `permission` Settings descriptor through `ctx.settingsScope` and writes one `settings.mutate` path operation with the descriptor revision; its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding, and a push invalidation refetches the descriptor. The value is read only when a later session is created. The current-session surface is a popupSelect decoration hung on the host `/permission` command (`ctx.commandUi.decorate`): the host command keeps its slash-menu row, argument-bearing form, and durable lifecycle logging, while the decoration replaces only the bare invocation with the picker. One process-scoped directory subscribes to the payload-free catalog notification before its first Remote read and publishes only the latest complete success for the active connection generation. A winning failure or connection reset clears the old snapshot and makes the picker locally unavailable until a later existing trigger retries; stale-generation and disposed settlements are ignored. Its public observables are `{ value }` and `invalidations`, one tick per catalog notification or connection-generation change; failures remain internal to imperative loading. Both the slash popup and composer seat consume that shared catalog, while the Session `permissions` projection supplies only `currentValue`. Full access and Auto review each carry localized confirmation copy; Auto also carries the badge rendered by the shared popup shell.
+The General row reads the explicitly exposed `permission` Settings descriptor through `ctx.settingsScope` and writes one `settings.mutate` path operation with the descriptor revision; its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding, and a push invalidation refetches the descriptor. The value is read only when a later session is created. The current-session surface is a popupSelect decoration hung on the host `/permission` command (`ctx.commandUi.decorate`): the host command keeps its slash-menu row, argument-bearing form, and durable lifecycle logging, while the decoration replaces only the bare invocation with the picker. One process-scoped directory subscribes to the payload-free catalog notification before its first Remote read and publishes only the latest complete success for the active connection generation. A winning failure or connection reset clears the old snapshot, which hides the composer seat until a later existing trigger retries, while the slash picker stays available and shows the failure with its retry; stale-generation and disposed settlements are ignored. Its public observables are `{ value }` and `invalidations`, one tick per catalog notification or connection-generation change; failures remain internal to imperative loading. Both the slash popup and composer seat consume that shared catalog, while the Session `permissions` projection supplies only `currentValue`. Full access and Auto review each carry localized confirmation copy; Auto also carries the badge rendered by the shared popup shell.
 
 </details>
 

+ 1 - 1
packages/client/ui-permission-presets/README.zh.md

@@ -45,7 +45,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-General Settings 行经 `ctx.settingsScope` 读取显式暴露的 `permission` Settings 描述符,并携带描述符 revision 写入一条 `settings.mutate` 路径操作;其 observable 经 slot 系统的 `hooks` compartment 传递,因此 React 钩子绑定归渲染器,推送失效通知会重新获取描述符。该值只在之后创建会话时读取。当前会话界面是挂在宿主 `/permission` 命令上的 popupSelect 装饰(`ctx.commandUi.decorate`):宿主命令保留斜杠菜单行、带参形式与持久生命周期记账,装饰只把裸调用替换为选择器。一个进程级目录会在首次 Remote 读取前订阅无 payload 的目录通知,并且只发布当前连接代际中最新的完整成功结果。胜出的读取失败或连接 reset 会清空旧快照,使选择器在后续既有触发重试前处于本地不可用状态;旧连接代际与 dispose 后才返回的结果会被忽略。它的公共 observable 是 `{ value }` 与 `invalidations`(每次目录通知或连接代际变更打一个点);失败仅供命令式加载内部使用。slash popup 与 composer seat 共用这份目录,而 Session `permissions` 投影只提供 `currentValue`。Full access 与 Auto review 各自携带本地化确认文案;Auto 还携带由共享 popup 外壳渲染的 badge。
+General Settings 行经 `ctx.settingsScope` 读取显式暴露的 `permission` Settings 描述符,并携带描述符 revision 写入一条 `settings.mutate` 路径操作;其 observable 经 slot 系统的 `hooks` compartment 传递,因此 React 钩子绑定归渲染器,推送失效通知会重新获取描述符。该值只在之后创建会话时读取。当前会话界面是挂在宿主 `/permission` 命令上的 popupSelect 装饰(`ctx.commandUi.decorate`):宿主命令保留斜杠菜单行、带参形式与持久生命周期记账,装饰只把裸调用替换为选择器。一个进程级目录会在首次 Remote 读取前订阅无 payload 的目录通知,并且只发布当前连接代际中最新的完整成功结果。胜出的读取失败或连接 reset 会清空旧快照:composer seat 因此隐藏,直到后续既有触发重试,而 slash 选择器保持可用并在弹窗内显示失败与重试;旧连接代际与 dispose 后才返回的结果会被忽略。它的公共 observable 是 `{ value }` 与 `invalidations`(每次目录通知或连接代际变更打一个点);失败仅供命令式加载内部使用。slash popup 与 composer seat 共用这份目录,而 Session `permissions` 投影只提供 `currentValue`。Full access 与 Auto review 各自携带本地化确认文案;Auto 还携带由共享 popup 外壳渲染的 badge。
 
 </details>
 

+ 8 - 3
packages/client/ui-permission-presets/src/client/catalog.ts

@@ -48,15 +48,17 @@ export class PermissionCatalogDirectory {
       this.refresh()
     })
     this.stopGeneration = this.connection.generation.subscribe(() => {
-      this.invalidate()
       this.syncGeneration()
     })
     this.syncGeneration()
   }
 
-  /** Publish one invalidation tick for consumers holding displayed options. */
+  /**
+   * Publish one invalidation tick for consumers holding displayed options.
+   * Both callers are disposal-guarded, and disposal unsubscribes them, so a
+   * disposed directory cannot reach this.
+   */
   private invalidate(): void {
-    if (this.disposed) return
     this.invalidations.set({ count: this.invalidations.getSnapshot().count + 1 })
   }
 
@@ -112,6 +114,9 @@ export class PermissionCatalogDirectory {
     if (this.disposed) return
     const generationId = this.connection.generation.getSnapshot()?.id
     if (this.initialized && generationId === this.generationId) return
+    // Only an actual change withdraws displayed options: the first sync, and a
+    // notification repeating the generation already published, leave them alone.
+    if (this.initialized) this.invalidate()
     this.initialized = true
     this.generationId = generationId
     ++this.epoch

+ 4 - 2
packages/client/ui-permission-presets/tests/catalog.client.spec.ts

@@ -237,8 +237,8 @@ describe('PermissionCatalogDirectory', () => {
 
     generation.set(1)
     expect(calls).toBe(1)
-    // A repeated notification for the same generation is not an invalidation.
-    expect(directory.invalidations.getSnapshot()).toEqual({ count: 1 })
+    // A notification repeating the published generation is not an invalidation.
+    expect(directory.invalidations.getSnapshot()).toEqual({ count: 0 })
 
     generation.setSilently(2)
     await expect(directory.load()).resolves.toEqual(SECOND)
@@ -276,6 +276,8 @@ describe('PermissionCatalogDirectory', () => {
     await Promise.resolve()
 
     expect(directory.store.getSnapshot()).toBe(before)
+    // A listener that fires after disposal publishes nothing.
+    expect(directory.invalidations.getSnapshot()).toEqual({ count: 0 })
     expect(calls).toBe(1)
     await expect(directory.load()).rejects.toThrow(/disposed/)
   })