This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Repository navigation
Commit 6d8bf0c
fix(app-shell,data-objectstack): concurrent identical Studio reads share one in-flight request (objectui#11797) (#11856)
Fixes #11797
Clause-②: no
## What changes
Opening a Studio package mounts several readers that ask the same
question at once, and each one sent its own request. A caller that
arrives while an identical request is still pending now waits for that
request instead of sending another:
- **`fetchPackages`** (`@object-ui/app-shell`,
`studio-design/packages-io.ts`): the package switcher, the writability
courtesy gate and the `DataPillar` namespace lookup share one `GET
/api/v1/packages`.
- **`fetchPendingDrafts`** (`@object-ui/app-shell`,
`preview/usePendingDrafts.ts`): the hook's mount read and the surface's
own mount `refresh()` (Studio top bar, chat bar) share one `GET
/api/v1/meta/_drafts` per scope.
- **`MetadataClient`** (`@object-ui/data-objectstack`,
`metadata-client.ts`): `listTypes`, `list`, `listDrafts` and `get` /
`getDraft` share one GET per method, URL and headers. The sharing works
across every client built on the same `fetch`, because the console
creates one client per component, all on one authenticated fetch. Every
caller, the first one included, gets an answer object no other caller
holds: the last caller to be handed the answer takes the parsed original
and every earlier one a `structuredClone` copy, so nothing is copied
when nobody joined (patch round 1, `bab3004`).
No call site changed. The `fetchPackages` call sites in
`StudioDesignSurface.tsx` and `BuilderLanding.tsx` are untouched: the
read is shared below them.
## Measured: the duplicates are concurrent mounts, and in-flight sharing
alone removes them (H1)
Requests are counted on the wire, through transports that hold every
request open until the test answers it. **Before** is the ablation run:
the three production files checked out at the base `455c646`, with the
tests unchanged.
| What | Before | After |
|---|---|---|
| The real `StudioDesignSurface` on the Data tab: `GET /api/v1/packages`
| 3 | 1 |
| The same render: `_drafts?packageId=…` | not reached (the packages
assertion fails first) | 1 |
| Two count surfaces (the hook plus their own mount refresh) on one
package: `_drafts` | 4 | 1 |
| Two `MetadataClient`s on one fetch, each calling `listDrafts`, `list`
and `get` | 6 | 3 |
| Three concurrent `fetchPackages()` calls | 3 | 1 |
The before counts match the card's "packages ×3" and "_drafts ×4"
readings.
## Design: in-flight sharing only, with no reuse window (H2)
I cannot name every write that must clear a cached answer. An agent
creates packages and stages drafts on the server, and the client never
sees those writes. So, per H2, this keeps **only** the in-flight
sharing, with no reuse window:
- An entry is dropped the moment its request settles, so a call made
after an answer always goes to the server.
- A read already on the wire when a write lands was asked before that
write, so a read asked after the write must not join it. The writes each
reader can see drop the pending entry:
- **`MetadataClient`:** every `save`, `publishDraft`,
`publishPackageDrafts`, `reset`, `publish` and `rollback` through a
client on the same `fetch` drops every pending read on that `fetch`. The
drop happens when the write's response arrives, whether the write
succeeded or failed.
- **packages:** `duplicatePackage` (the same module) and the existing
`objectui:packages-changed` announcement (create and edit, from
`PackageFormDialog`).
- **drafts:** the publish pulse (`emitMetadataRefresh`), which the
module doc already names as the contract every publish path keeps. The
drop runs before the hooks it wakes read again, so those hooks share
**one** new request.
- **Residual:** a write the reader cannot see (a package delete or
disable through `PackagesPage`, a draft save while a raw `_drafts` read
is pending, any server-side agent write) can still be answered by a read
that was already on the wire. The window is one round trip, and the same
residual applies to the adapter's existing `find` sharing. A read asked
after that pending read settles always goes to the server.
Failures are shared, not remembered (H3). A failed request rejects every
caller that was waiting on it, and the next call retries. Tests cover an
HTTP failure and a transport failure (`fetch` rejecting) for the
metadata client.
No exported signature changes (H4). There is no new export, method,
option or type. `shareRead`, `sendWrite`, `readPackagesPayload` and
`readDraftsPayload` are all module-private or `private`.
## `sys_user_preference`: no duplicate to remove (the premise does not
hold for this family)
The card reported three `GET /api/v1/data/sys_user_preference` reads
"with the identical filter" on Home → Studio. I measured this through
the real `ObjectStackAdapter` and client
(`userState.inflightShare-11797.test.ts`). `UserStateBridge` attaches
three adapters, one each for `ui.favorites`, `ui.recent` and
`ui.flow.palette.recents`, and each one loads its own row. The result is
three requests whose filters differ only in the `key` predicate, which
sits at the tail of a long encoded query string. They are three
different questions, so one read per key is already met. The real
duplicate case is two loads of one `(user, key)` at once, and the
adapter's existing `find` sharing already sends that as one request
(pinned). **`userState.ts` is not changed.**
## Files
- `packages/app-shell/src/views/studio-design/packages-io.ts`
- `packages/app-shell/src/preview/usePendingDrafts.ts`
- `packages/data-objectstack/src/metadata-client.ts`
- `packages/data-objectstack/README.md`: the **Shared In-Flight Reads**
bullet now covers `MetadataClient` (patch round 1)
- Tests: `packages-io.inflightShare-11797.test.tsx`,
`StudioDesignSurface.entryReadsOnce-11797.test.tsx` (a new test file
beside `packages-io.ts`; `StudioDesignSurface.tsx` itself is not
edited), `usePendingDrafts.inflightShare-11797.test.tsx`,
`metadata-client.inflightShare-11797.test.ts`,
`userState.inflightShare-11797.test.ts`
- `.changeset/11797-shared-inflight-reads.md`: patch for
`@object-ui/app-shell` and `@object-ui/data-objectstack`
## Verification
Round 0 ran at `c3759d6`. Patch round 1 re-ran at `bab3004` (the final
commit): new pins, 5 files / 32 tests passed; regression, the same
247-file set, 247 files / 2233 tests passed; app-shell and
data-objectstack type-check exit 0. The figures below are round 0's.
- **New pins** (5 files, run with `pnpm exec vitest run` from the repo
root): `Test Files 5 passed (5)`, `Tests 27 passed (27)`.
- **Ablation:**
- Each production file was checked out at `455c646`, and on-disk markers
confirmed the swap: the hash equals the BASE blob for all 3 files.
- Result: `Test Files 4 failed | 1 passed (5)`, `Tests 12 failed | 15
passed (27)`. The `sys_user_preference` file stays green because it pins
existing behaviour.
- Restored with `git checkout HEAD --`: the hash equals the HEAD blob
for all 3 files, and `git diff HEAD` is empty. After the restore: `Test
Files 5 passed (5)`.
- **Regression:**
- Scope: every test file that imports a touched module or builds a
`MetadataClient`, plus all of `packages/data-objectstack/` and
`StudioRoute`. That is 247 files across app-shell, data-objectstack,
plugin-designer, plugin-form, plugin-kanban, plugin-calendar, react and
console.
- Result: `Test Files 247 passed (247)`, `Tests 2228 passed (2228)`.
- This is a declared narrowing. The full 8-shard `pnpm test` is left to
CI.
- **Builds and type-checks:**
- `turbo run build --filter=@object-ui/app-shell^...`: exit 0.
- `pnpm --filter @object-ui/data-objectstack type-check`: exit 0.
- `pnpm --filter @object-ui/app-shell type-check`, which runs both `tsc
--noEmit` and `tsc -p tsconfig.test.json` (the second includes
`src/**/*.test.ts(x)`): exit 0.
- **Lint:** `pnpm exec eslint` on the 8 touched source and test files:
exit 0, with 0 errors and 9 warnings. All 9 warnings are on lines that
were already there (`any` casts in `listTypes`, `setState` in the
effect).
- **Repo gates:**
- `node scripts/check-changeset-presence.mjs`: `✅ 8 source file(s) of 2
released package(s) changed, and this change declares 1 changeset(s)`.
- `pnpm check:new-line-citations`: `VERDICT
new-cross-file-line-citations: 0 new citation(s)`.
- `pnpm check:control-bytes`: `✅ check-control-bytes: OK`.
- Also exit 0: `check:vi-mock-specifiers`, `check:vi-mock-inherit`,
`check:vi-mock-override-shape`, `check:test-path-roots`,
`check:metadata-write-doors`, `check:changeset-claims`,
`check:pending-changeset-literals`, `check:side-effects-array`,
`check:unreferenced-sources`, `check:esm-specifiers`,
`check:handler-key-reads`, `check:phantom-deps`, `check:self-import`,
`scripts/check-type-check-coverage.mjs`, and `check-governed-queue-guard
--test` (NOT GOVERNED).
- **NOT MEASURED:**
- `check:published-dist`: it builds every package including the console
app, which is a repo-wide run that CI owns. A local attempt was stopped
by a 300 s timeout before it printed a verdict.
- `check:dist-completeness --all` for app-shell: app-shell itself was
not built locally (only its dependency closure was). The gate reported
only that unbuilt package, and no other.
## Acceptance notes (not fixed here, not filed)
- **README gap, resolved in patch round 1** (`bab3004`, seat verdict
`6050853625`). The data-objectstack README bullet "Shared In-Flight
Reads" describes only the adapter's `find` and `findOne`. A sentence
about `MetadataClient` sharing is owed under AGENTS.md #2, but the
README is outside this card's claimed file surface, so I left it for the
seat.
- **`find` is not dropped by writes.** `ObjectStackAdapter.emitMutation`
drops the pending `findOne` reads of the resource it writes, but not the
pending `find` reads. A list refetch asked after a write can therefore
join a `find` that was sent before the write. This was found by reading
the code, with no public-door reproduction.
- **A second packages reader.** `ContextSelectors` (the app top bar)
reads its options endpoint with its own `fetch`, separately from
`fetchPackages`. It is not part of the Studio entry path.
- **Not measured:** whether Home → Studio remounts `ConnectedShell` and
so re-attaches the user-state bridge. That would explain why the three
preference reads repeat on that navigation, but they are still one read
per key.
- **Hung requests.** A request that never settles keeps its joiners
waiting. This is the same property as the adapter's existing `find` /
`findOne` sharing.
- **Overlap with objectui#11799.** That card is queued behind this one
and touches the `?state=draft` probe. This PR changes only the transport
under `readItemResponse` (now wrapped in `shareRead`); the probe logic
is unchanged.
The implementing session is
`https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU` (seat
`domain:ui#2`).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent cef0eee commit 6d8bf0c
10 files changed
Lines changed: 1302 additions & 58 deletions
File tree
- .changeset
- packages
- app-shell/src
- preview
- views/studio-design
- data-objectstack
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
44 | 107 | | |
45 | 108 | | |
46 | 109 | | |
47 | 110 | | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
| 111 | + | |
55 | 112 | | |
56 | 113 | | |
57 | 114 | | |
| |||
0 commit comments