Skip to content

perf(console): share one in-flight GET instead of racing duplicates on boot - #5658

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5544-request-dedup
Aug 22, 2026
Merged

perf(console): share one in-flight GET instead of racing duplicates on boot#5658
os-zhuang merged 2 commits into
mainfrom
claude/issue-5544-request-dedup

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5544

What was actually duplicated

I re-derived every row of the card's two tables against origin/main before writing
anything. Three of the five endpoints are not duplicates, and the earlier round on
this card (#5593, merged) already
established two of them:

endpoint verdict on main
data/sys_user_preference ×3 not a duplicate. ConsoleShell.tsx:306/311/316 attaches three adapters with three distinct keys (ui.favorites, ui.recent, ui.flow.palette.recents). The card's probe groups by new URL(r.name).pathname, which discards the query string the key predicate rides in. Pinned by #5593.
meta/object ×2, meta/view ×2 already closed by objectui#4042 and pinned by MetadataProvider.requestBudget.test.tsx.
auth/get-session ×2 by design. apps/console/src/lib/auth-preflight.ts:52 probes Bearer-only with credentials: 'omit', precisely so the cookie cannot mask a stale token; AuthProvider then asks by cookie. Deduping this destroys the signal the first call exists to read.
runtime/config ×2 real, and previously unattributed. Two callers, both in this repo.
auth/me/localization ×2 real, on a device's true first visit.

The two real ones are what this PR closes. Both are the shape the card describes — separate
callers with no shared provider between them, so no per-component guard (the #1477 technique)
can see across them.

GET /api/v1/runtime/config — the pre-React branding script inlined in
apps/console/index.html:25 fetches it during HTML parse, and initRuntimeConfig()
(packages/app-shell/src/runtime-config.ts:163) fetched it again from the module chunk.
This is the expensive one: apps/console/src/main.tsx:73 awaits initRuntimeConfig()
inside the Promise.all that gates createRoot().render(), so the duplicate sat on the
critical path to first paint. Joining the earlier request removes a round trip and lets
that await settle sooner, because it inherits a request that started hundreds of ms before
the bundle was even fetched.

GET /api/v1/auth/me/localizationseedTenantLanguage() keeps its request running
past the 500 ms race by design, and LocalizationFetchProvider mounts the moment that race
resolves, so on a first visit the two overlap. languageSeed.ts's own docblock already
describes them as one request ("without a second request here"); they were two.

The mechanism

@object-ui/types gains sharedGetJson() (sibling to http-retry.ts, in the lowest
package every caller can reach). It shares the in-flight promise and nothing else:

  • the registry entry is deleted the instant the request settles — no cache, no TTL, no
    stale window. A caller arriving after settle fetches fresh, exactly as before;
  • a rejection fans out to every sharer with the status intact, so
    LocalizationFetchProvider's retry policy still sees its own 503 + Retry-After;
  • each caller receives its own copy of the parsed body, so two consumers of one request
    are as independent as two consumers of two requests;
  • GET only — a non-GET is refused rather than quietly rewritten;
  • a request carrying an AbortSignal opts out entirely (it neither joins nor is joined):
    a signal belongs to one caller, and sharing behind two would let either caller's abort
    cancel the other's read.

Requests differing in credentials mode or headers keep separate identities, which is what
keeps the deliberate get-session pair a pair.

Why the registry lives on globalThis

One of the two runtime/config callers is an inline classic script — it has to run
before any module chunk executes, so it cannot import. A module-scoped Map could never
reach it, and the most valuable duplicate would have survived the fix.
Symbol.for('objectui.inflightGet') gives both worlds one registry with no import and no
bundler cooperation. The script spells the request key out by hand; the drift that creates
is closed mechanically, not by comment — apps/console/src/__tests__/runtimeConfigBootDedup.test.ts
extracts the shipped script's text out of index.html and executes it (the pattern
insecure-origin-crypto.test.ts already uses), and http-inflight.test.ts asserts
inflightGetKey() produces the exact string that script builds.

Verification — all at final head a6bd4f812

Exit codes captured before any pipe.

  • New tests, 22 cases across packages/types/src/__tests__/http-inflight.test.ts,
    apps/console/src/__tests__/runtimeConfigBootDedup.test.ts and
    apps/console/src/__tests__/localizationBootDedup.test.tsx. They pin all four properties
    the card asks for — N concurrent same-key GETs ⇒ one call and every caller served;
    rejection fans out; different keys never share; a wave after settle refetches — plus the
    end-to-end boot through the real index.html.
  • Test union: Test Files 215 passed (215) / Tests 1950 passed (1950)
    (packages/types/, apps/console/, packages/app-shell/src/{console,layout,providers}/,
    runtime-config.test.ts, and the i18n / gantt localization consumers).
  • Type-check, @object-ui/types + @object-ui/app-shell + @object-ui/console: exit 0,
    each script name echoed (so not a zero-match no-op), after building the console's
    dependency closure.
  • Lint: full-repo pnpm lint exit 0 (0 errors repo-wide); eslint --no-inline-config --format json over the 7 changed files reports files linted: 7, errors: 0, warnings: 0.
    check-lint-coverage: 46/46 packages linted, 0 with outstanding errors.
  • check-control-bytes: OK (scanned 4708 tracked text file(s); skipped 85 binary).
  • Changeset gates: presence ✅ 8 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s); ✅ No changeset declares a major bump;
    ✅ All workspace packages are in the changeset fixed group.

Ablation

The join was disabled at its only decision point — const existing = reg.get(key) became
const existing = undefined in sharedGetJson, i.e. the sharing removed while the
registration stays. No rebuild is required and that is proven rather than assumed:
vitest.config.mts:261 aliases @object-ui/types to packages/types/src, so the suites
load mutated source. The mutation was confirmed on disk by anchored counts in both
directions (original anchor 1 → 0, mutant anchor 0 → 1, 1 file changed, 1 insertion(+), 1 deletion(-)).

Result: 9 of 21 cases red, every one of them the duplicate reappearing — expected "vi.fn()" to be called 1 times, but got 2 times (and 3, and 5). The negative controls
correctly stayed green: different keys still did not share, the key-format assertions
held, and the GET-only refusal held. Restored by a trap … EXIT INT TERM; the restore leg
was verified the same way (original anchor back at 1, mutant at 0, git status --porcelain
empty).

What I did not measure

The card's reproduce snippet is a browser probe against prod, and I did not take a local
equivalent, because a faithful one is not available from this repo: the sharing is in-flight
only, so it requires the two calls to overlap, and a Vite dev server answers
/api/v1/runtime/config with an instant 404 — the pre-boot request settles before the module
chunk runs and both fixed and unfixed builds show ×2. The prod/staging numbers in the card
are its measurement, not mine. What the win depends on is stated plainly: it is proportional
to server latency, and the card's own prod durations (512 / 431 ms for a fetch that starts at
parse time) show the overlap is real there.

Scope

out of scope: #5593 — its pin stays exactly as merged. No component receives anything
different: same payloads, same errors, one fewer round trip.


Generated by Claude Code

hotlong and others added 2 commits August 22, 2026 09:30
…n boot

Fixes #5544

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An AbortSignal belongs to one caller; sharing behind two signals would let
either caller's abort cancel the other's read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@os-zhuang

Copy link
Copy Markdown
Contributor Author

PM review — Q1: A. Q2: A. And the falsified premise is mine to own, so let me do that first.

The card's headline was my measurement error

sys_user_preference ×3–×6 came from my browser probe grouping resource entries by pathname only. The three requests carry three distinct query keys (ui.favorites / ui.recent / ui.flow.palette.recents, ConsoleShell.tsx:306-316) — three questions, not one asked thrice. The meta/* pairs were already closed under #4042, and get-session ×2 is deliberate (Bearer-probe then cookie). Re-deriving every claim on current main instead of inheriting the card was exactly right, and premise_still_valid: false is the correct verdict to file.

For the record, that is the third measurement-methodology error of mine this epic has caught: the circular ~35-query estimate (cloud#1518), the stale staging baseline (cloud#1546), and now pathname-only grouping. All three share a shape — a number that looked measured but carried an unexamined assumption — and all three were caught by someone re-measuring rather than trusting the card.

What survived is worth having

Two real duplicates, both attributed to the line: runtime/config ×2 — the pre-React branding script inlined in index.html fetches during HTML parse, then initRuntimeConfig() fetches again from the module chunk, on the critical path of every cold load (main.tsx awaits it before render). And auth/me/localization ×2 on first visit (the 500 ms seed race vs provider mount). One in-flight-only primitive closes both; entry deleted at settle, no cache, rejections fan out, AbortSignal opts out. Correct shape.

Q2 — scope: A, and the reasoning deserves quoting

a generic request-layer coalescer, added today, would collapse ZERO of the card's measured duplicates

ObjectStackAdapter.find() already coalesces; the remaining duplicates were raw bootstrap fetches. Routing every workspace GET through the new primitive would be the widest possible answer to a defect that is now two callers wide — if B is ever wanted it needs its own card with its own measurement. Agreed on all points.

Q1 — branch: A. The round-a name in the claim comment came from my batch template placeholder (second occurrence tonight; also on cloud#1560). The PR is the durable pointer; claim amended.

The ablation

9 of 21 red with sharing removed, every failure the duplicate reappearing, negative controls green, alias-to-source proven rather than assumed, restore verified in both directions with a clean tree. That is the standard.

Marking the finding you could not file (API rate limit) as filed by the PM. Ready + enqueue when CI is green.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3912.2 KB 3990.2 KB
Main entry chunk (gzip) 151.6 KB 350 KB
Entry file index-BCGIKGjw.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 10.04KB 3.72KB
app-shell (runtime-config.js) 12.80KB 4.47KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.34KB 7.05KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 6.35KB 2.43KB
auth (index.js) 2.77KB 1.22KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.89KB
auth (useIsWorkspaceAdmin.js) 3.04KB 1.45KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 506.21KB 113.58KB
core (index.js) 4.51KB 1.80KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 160.38KB 44.54KB
fields (index.js) 238.85KB 60.13KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.65KB 18.32KB
plugin-chatbot (index.js) 181.41KB 43.22KB
plugin-dashboard (index.js) 128.33KB 32.93KB
plugin-designer (index.js) 212.30KB 42.80KB
plugin-detail (index.js) 242.16KB 60.90KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 125.07KB 30.43KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 200.79KB 54.26KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.74KB 27.18KB
plugin-map (index.js) 20.06KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.54KB 20.69KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 43.66KB 14.77KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.59KB 1.79KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang marked this pull request as ready for review August 22, 2026 02:25
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 22, 2026
Merged via the queue into main with commit 0e05aac Aug 22, 2026
23 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5544-request-dedup branch August 22, 2026 02:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Console duplicates requests within one cold load (sys_user_preference ×3–×6, meta/object ×2, meta/view ×2)

2 participants