Skip to content

Commit b177920

Browse files
perf(app-shell): a platform admin reads the package list once per session for the app-to-Studio entries (#29240)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbP3ZkhgACTq7KozHL2hkAAX)_ Fixes #29119 Clause-②: no Metadata-protocol: none The two lines are copied from the claim (comment 6572848864). ## 维护者速读 - 改了什么:平台管理员进应用页时,顶栏「在 Studio 中编辑应用」铅笔、侧栏 Design 入口和页面铅笔用来判断能否绘制的包列表(`GET /api/v1/packages`),现在按会话(登录用户 + 当前组织)只读一次并持有;切换应用不再重读。Studio 自己的包列表读取不变,仍每次读服务器。 - 为什么改:这个列表是应用页上最大的一笔响应(staging 实测编码 317 KB),之前每进一个应用页都重读一遍,同一页的读者晚挂载时还会读两遍,只为决定一个铅笔是否可点。 - 风险与回滚:持有的答案可能过期。本标签页内的每一次包写入(新建、编辑、复制、定制配对、包页面的发布/回退/丢弃/删除/认领/启停/导入、市场安装与卸载)都会清掉它;列表里没有的包 id(例如 agent 在服务端新建的包)会重读一次;别处改变某个已列出包的可写判定时,入口会显示旧姿态,直到本页写包、换会话或刷新,Studio 打开时仍按自己新读的判定,服务端写门禁不变。回滚即撤销本 PR。非管理员仍不发这个请求。 - 先看:`packages-io.ts` 新增的 `fetchSessionPackages` 与 `forgetPackageListReads`;`useStudioDesignGate.ts` 的 `usePackagePosture`;新测试 `useStudioDesignGate.sessionHold-29119.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/hooks/useStudioDesignGate.sessionHold-29119.test.tsx`,预期 15 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes - `packages-io.ts` gains `fetchSessionPackages(session, packageId)`: the package list the app → Studio entries read, held per `fetch` transport for one session once read. A first ask (and concurrent first asks) goes through the existing in-flight share; later asks answer from the hold. The hold carries its session (the signed-in user id and the active organization id), so a sign-in as someone else or an in-page organization switch reads again. An id the held list does not name, kernel packages counted as named, is read again once per hold, so a package created on the server since (an agent, another tab) still gets its entry, and an id no list names is not re-read on every visit. A failed read is not held, and a read still on the wire when a write lands answers its callers but does not become the hold. - `fetchPackages` and every other Studio reader are unchanged and hold nothing. The documented reason they are not a response cache (no client-side list can name every write, an agent creates packages server-side) still binds a list that must show the server's current answer. The gate's hold keeps to the bounds stated in `fetchSessionPackages`' doc. - `forgetPackageListReads()` replaces the module-private drop: it drops the pending list read and the session's hold. Every package write in the ui tree now calls it however the write ends: - in `packages-io.ts`: `duplicatePackage`, `ensureCustomizationPackage` and the `objectui:packages-changed` listener (which covers `PackageFormDialog`'s create and edit), as before for the pending read; - `PackagesPage.tsx`: its `apiJson` helper on any non-GET request, which is every write the page sends (publish, revert, discard-drafts, delete, adopt-orphans, enable/disable, and the JSON import through `install-local`); - `marketplaceApi.ts`: `installLocal`, `uninstallLocal`, and `installPackage`'s same-origin `cloud-connection/install` leg (the control-plane leg installs into another environment, whose list this tab does not hold). - `useStudioDesignGate.ts`: `usePackagePosture` reads `fetchSessionPackages` with the session from `useAuth()`, and its verdict carries the session it answers for. The Cost section of its docblock now states the per-session read. What a held answer can still get wrong: a writable verdict that flips outside this tab on a package the list already names. The entry then shows the previous posture until this tab writes a package, the session changes or the page reloads. A stale edit pencil opens Studio, which reads the verdict itself and opens read-only; a stale view eye costs a click. The server's write gate is the authority either way. ## Tests - New `ui/packages/app-shell/src/hooks/useStudioDesignGate.sessionHold-29119.test.tsx` (15 cases), the real hooks over the real `packages-io` reads with every request counted on a stubbed `fetch`: - one app page's four readers send one list read, including a reader mounted after the read settled; - moving through five apps and back sends none, and the held list still answers each app's posture (edit, view, none for the kernel Setup package); - each package write (duplicate, ensured customization pair, the packages-changed announcement, marketplace install, marketplace uninstall) makes the next ask read again and see the new verdict, which is then held; - a read on the wire when a duplicate lands answers but is not held; - an id created since the hold is read again once and gets its entry; an id no list names is read again once in three visits; - another organization and another user each read their own list; - a non-admin and an admin the server refuses authoring send no list read; - `fetchPackages` still reads the server on every call. - Ablation (one-shot, `scripts/ablation-replace.mjs`, restored to HEAD's blob `fbaa0b6d`, `git diff HEAD` empty): with the hold's answer branch disabled, 10 of 15 cases go red on the request count (for example "expected 6 to be 1" for the app switches); the session, non-admin, in-flight and Studio cases stay green, as they should. ## Acceptance notes - Acceptance 2 (cloud `verify-console-pin.mjs` on welcome) is NOT MEASURED here: that script lives in the cloud repository. The same property (no repeated list read on one admin app page load, none across app switches) is measured by the first two cases above. - The optional server ETag on `packages/rest/src/package-routes.ts` is out of this card (domain:server). With the session hold the console sends one list read per session, so an ETag would save the one remaining body per session and Studio's own reads; noted, not filed. ## Verification Readings on head `49bae7f9`, which merges `origin/main` `dedc3505` into the branch. <details><summary>Tests</summary> - New file: 15 passed. - Consumer sweep on the pre-merge head `62a695fc`, every test file naming the gate, `packages-io`, `PackagesPage`, `marketplaceApi`, `AppHeader`, `SidebarItemMenu`, `PageView`, the app switcher, the marketplace pages or `HomePage` (195 files in five batches): 1982 tests passed. - After the merge of main, the 73 files naming the gate, `packages-io`, `marketplaceApi` or `PackagesPage` (Studio pillar suites excluded): 693 tests passed. - `pnpm --dir ui exec vitest run scripts/`: 169 files passed, 2 skipped; 4900 tests passed. </details> <details><summary>Type-check and gates</summary> - `pnpm --filter @object-ui/app-shell run type-check` (source and test programs), on the pre-merge head `d5f99fa0`: exit 0. The merge of main touched none of this PR's files; CI's Type Check UI is the reading on the merged head. - `node scripts/pm/dispatch-gates.mjs --commands` derived 34 families for this diff: all 34 ran with exit 0, and `--ran` reconciled 34 derived, 34 run, 0 NOT MEASURED. The changeset and closing-keyword gates ran against this body. - `node scripts/check-sdui-manifest.mjs`: exit 0 (no registry change). - `pnpm check:new-line-citations` (under `ui/`): 0 new citations. - The whole-tree families and CI-only lanes are CI's; their check-runs on this PR are the reading. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jREm6PtRBXSCPhrGQE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7788eed commit b177920

6 files changed

Lines changed: 545 additions & 31 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
perf(app-shell): a platform admin reads the package list once per session for the app → Studio entries, not on every app page
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The app bar's Edit app pencil (and the app switcher's Studio row), the sidebar's Design entry and a page's Edit in studio pencil decide whether to draw by reading `GET /api/v1/packages`. That read was repeated on every app page a platform admin opened, and twice on one page when a reader mounted after the first read had settled, although the list is the largest response such a page reads. Those entries now hold the list for the session (the signed-in user and the active organization): an admin's session reads it once, and moving between apps reads nothing. Non-admins still send no such request.
11+
12+
Only these entries hold the list. Studio's own package reads (the switcher, the landing page, the writability gates) still read the server every time.
13+
14+
A held answer stays correct when the list changes: every package write the Console sends forgets it (create and edit, duplicate, the customization pair, the Packages page's publish, revert, discard, delete, adopt, enable/disable and import, and the marketplace install and uninstall), and a package id the held list does not name, such as a package an agent created on the server, is read again once. A writable verdict that changes outside this tab shows its previous posture until this tab writes a package, the session changes or the page reloads; Studio still opens on the verdict it reads itself, and the server's write gate is unchanged.

‎ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515

1616
import { getCloudBase } from '../../runtime-config.js';
17+
import { forgetPackageListReads } from '../../views/studio-design/packages-io.js';
1718
import { TokenStorage } from '@object-ui/auth';
1819

1920
const SERVER_URL = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
@@ -334,6 +335,8 @@ export async function installPackage(input: {
334335
// by hostname on the server. See
335336
// docs/design/cloud-account-binding-marketplace-install.md (cloud repo).
336337
if (cloudBase) {
338+
// objectstack#29119 — the install lands in THIS environment, so its
339+
// package list reads from before it are forgotten however it ends.
337340
const res = await fetch(`/api/v1/cloud-connection/install`, {
338341
method: 'POST',
339342
credentials: 'include',
@@ -342,7 +345,7 @@ export async function installPackage(input: {
342345
package_id: input.packageId,
343346
seed_sample_data: !!input.seedSampleData,
344347
}),
345-
});
348+
}).finally(forgetPackageListReads);
346349
let payload: any = null;
347350
try { payload = await res.json(); } catch { /* empty */ }
348351
if (!res.ok || payload?.success === false) {
@@ -871,6 +874,7 @@ export async function installLocal(input: {
871874
versionId?: string;
872875
installMode?: LocalInstallMode;
873876
}): Promise<LocalInstallResult> {
877+
// objectstack#29119 — an install changes this environment's package list.
874878
const res = await fetch(`${API_BASE}/install-local`, {
875879
method: 'POST',
876880
credentials: 'include',
@@ -880,7 +884,7 @@ export async function installLocal(input: {
880884
...(input.versionId ? { versionId: input.versionId } : {}),
881885
...(input.installMode ? { installMode: input.installMode } : {}),
882886
}),
883-
});
887+
}).finally(forgetPackageListReads);
884888
let payload: any = null;
885889
try { payload = await res.json(); } catch { /* empty */ }
886890
if (!res.ok) {
@@ -1000,11 +1004,12 @@ export async function readMarketplaceInstalls(): Promise<{ source: 'cloud' | 'lo
10001004
}
10011005

10021006
export async function uninstallLocal(manifestId: string): Promise<void> {
1007+
// objectstack#29119 — an uninstall changes this environment's package list.
10031008
const res = await fetch(`${API_BASE}/install-local/${encodeURIComponent(manifestId)}`, {
10041009
method: 'DELETE',
10051010
credentials: 'include',
10061011
headers: { 'Accept': 'application/json' },
1007-
});
1012+
}).finally(forgetPackageListReads);
10081013
if (!res.ok) {
10091014
const payload: any = await res.json().catch(() => ({}));
10101015
throw new Error(readApiError(payload, res).message);

0 commit comments

Comments
 (0)