实现 objectstack#5891(PR objectstack#5984,给 content/docs/ui/apps.mdx 补 contextSelectors 散文)时核对真实消费方发现的越界项,未在该 PR 内修;那份文档已就此加了一条「One scope per app today」的警示 callout,以免作者照着数组类型声明出一份静默失效的元数据 —— 这里是把实现侧的账单独记下来。
现象
packages/app-shell/src/layout/ContextSelectors.tsx 的 useAppContextSelectors 把 URL 上的作用域键硬编码成字面量 'package',而不是从 selector 的 id 推导。三处:
- 重新施加已记住的作用域:
if (p.get('package')) continue; … p.set('package', saved);
- 写入选中值:
if (value) next.set('package', value); else next.delete('package');
- 读回当前值:
contextValues[sel.id] = (params.get('package') ?? saved) || (sel.allValue ?? '');
sessionStorage 那一半倒是按 selector 分开的(objectui-ctx-${appName}-${sel.id}),但只要 URL 上有 ?package=,?? 左边命中,所有 selector 的 contextValues 都取到同一个值。
后果
- 第二个 selector 不成立。 spec 的
App.contextSelectors 是数组(AppContextSelectorSchema[]),.describe 明说「selected value is exposed as the nav template var { id }」—— 按 id 命名。今天声明 active_package + active_env 两个,切任一个都写同一个 query key,两个模板变量拿到同一个值。作者得到的是一份 parse 通过、渲染出两个下拉、语义静默错乱的元数据。目前只有 Studio 一个内置 app 用了单个 selector,所以线上没人踩到 —— 但这正是 ADR-0049「声明了就得兑现」要防的形状。
persist 的三个取值没有被区分。 schema 声明 'query' | 'session' | 'none'(默认 'query'),实现里 'none' 只是跳过了「从 sessionStorage 重新施加」那个 effect;setValue 无论如何都同时写 sessionStorage 和 query 参数,所以 'none' 并没有「不持久化」,'session' 与 'query' 也没有区别。同样是 declared-but-unenforced 一类。
建议范围
- 作用域键改为按 selector 派生(如
sel.id,或显式声明的 query 键),读写两侧一致;Studio 现有的 ?package= 需要一条兼容路径 —— active_package 与 package 不同名,这一步是真的迁移而不是纯改名(UnifiedSidebar.tsx:345 也直接读 ?package,一并算进来)。
persist 要么按声明兑现三个取值,要么按 ADR-0049 走 enforce-or-remove 把它收掉(spec 侧的事,需跨仓协调)。
- 两条都值得配 selector 数量大于 1 的单测 —— 今天没有任何用例声明第二个 selector,所以这个洞在测试里也是不可见的。
真值来源:spec packages/spec/src/ui/app.zod.ts 的 AppContextSelectorSchema;消费方 ContextSelectors.tsx / AppSidebar.tsx / UnifiedSidebar.tsx / packages/layout/src/NavigationRenderer.tsx(applyNavTemplate 按变量名查 contextValues,所以它那一侧本来就是按 id 分开的)。
参考:objectstack#5891 / PR objectstack#5984(本发现的来源)。
Generated by Claude Code
实现 objectstack#5891(PR objectstack#5984,给
content/docs/ui/apps.mdx补contextSelectors散文)时核对真实消费方发现的越界项,未在该 PR 内修;那份文档已就此加了一条「One scope per app today」的警示 callout,以免作者照着数组类型声明出一份静默失效的元数据 —— 这里是把实现侧的账单独记下来。现象
packages/app-shell/src/layout/ContextSelectors.tsx的useAppContextSelectors把 URL 上的作用域键硬编码成字面量'package',而不是从 selector 的id推导。三处:if (p.get('package')) continue;…p.set('package', saved);if (value) next.set('package', value); else next.delete('package');contextValues[sel.id] = (params.get('package') ?? saved) || (sel.allValue ?? '');sessionStorage那一半倒是按 selector 分开的(objectui-ctx-${appName}-${sel.id}),但只要 URL 上有?package=,??左边命中,所有 selector 的contextValues都取到同一个值。后果
App.contextSelectors是数组(AppContextSelectorSchema[]),.describe明说「selected value is exposed as the nav template var{ id }」—— 按 id 命名。今天声明active_package+active_env两个,切任一个都写同一个 query key,两个模板变量拿到同一个值。作者得到的是一份 parse 通过、渲染出两个下拉、语义静默错乱的元数据。目前只有 Studio 一个内置 app 用了单个 selector,所以线上没人踩到 —— 但这正是 ADR-0049「声明了就得兑现」要防的形状。persist的三个取值没有被区分。 schema 声明'query' | 'session' | 'none'(默认'query'),实现里'none'只是跳过了「从 sessionStorage 重新施加」那个 effect;setValue无论如何都同时写 sessionStorage 和 query 参数,所以'none'并没有「不持久化」,'session'与'query'也没有区别。同样是 declared-but-unenforced 一类。建议范围
sel.id,或显式声明的 query 键),读写两侧一致;Studio 现有的?package=需要一条兼容路径 ——active_package与package不同名,这一步是真的迁移而不是纯改名(UnifiedSidebar.tsx:345也直接读?package,一并算进来)。persist要么按声明兑现三个取值,要么按 ADR-0049 走 enforce-or-remove 把它收掉(spec 侧的事,需跨仓协调)。真值来源:spec
packages/spec/src/ui/app.zod.ts的AppContextSelectorSchema;消费方ContextSelectors.tsx/AppSidebar.tsx/UnifiedSidebar.tsx/packages/layout/src/NavigationRenderer.tsx(applyNavTemplate按变量名查contextValues,所以它那一侧本来就是按 id 分开的)。参考:objectstack#5891 / PR objectstack#5984(本发现的来源)。
Generated by Claude Code