Skip to content

perf(permissions): build both providers' context value where React cannot discard it - #6863

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6813-permission-provider-ctx-identity
Aug 30, 2026
Merged

os-sam merged 1 commit into
mainfrom
claude/issue-6813-permission-provider-ctx-identity

Conversation

@claude

@claude claude Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #6813

Both permission providers built their context value in a useMemo — PermissionProvider over four useCallbacks, MePermissionsProvider over six. Neither carries a semantic guarantee: React may discard the cache and recompute even when the dependency list compares equal, and every one of those factories builds a fresh object. A discard therefore handed PermCtx.Provider a NEW context value with every permission it carries unchanged, which moves the key usePermissions() caches on after #6819 and re-runs the whole consumer chain.

This is hardening, not a repair

Nothing misbehaves today, and the card proves it rather than asserting it. On this repo's pinned React 19.2.8 the cache is not discarded spontaneously — 51 re-renders with no provider, 51 with one and 42 under StrictMode each returned ONE identity — and this repo has no Activity/Offscreen subtree, the documented case where React does throw memo caches away. What this PR removes is the dependency on React continuing not to exercise a licence it holds. The changeset, the code comments and the pin header all say so; none of them describe a bug being fixed. Type Task, not Bug.

The route

Per triage, no value key was derived and none is available: the context carries verdict FUNCTIONS over an open set of object/field names, which flatten to no fixed primitive list. The route taken is the one #6819 landed for usePermissions() one link down the chain — cache the value where React cannot drop it, keyed on the inputs it is derived from.

packages/permissions/src/discardProofCache.ts (new, internal, deliberately NOT exported from index.ts) is that cache: a nested module-level WeakMap keyed on the identities of the inputs. Both providers use the one idiom.

Two design points worth a reviewer's attention, both documented at the call sites:

Dependency sets are unchanged. Each cached member keeps exactly the inputs its old useCallback named, which matters: useFieldPermissions names checkField and getFieldPermissions in its own dependency arrays, and RecordDetailView / ObjectDataPage / ObjectView each name getObjectApiOperations. Folding the members into one value-level key would have churned those on a roles or user change that today leaves them alone. Nothing here churns more often than it did.

Three members that were rebuilt per value and can never vary are now module constants — PermissionProvider's getObjectApiOperations and hasCapabilities, and MePermissionsProvider's getRowFilter (previously useCallback(..., []), which React may also discard). That is strictly stabler for the three consumers naming getObjectApiOperations in a dependency array.

No published export changes and the context carries exactly what it carried before.

Premise check against origin/main

The card's line numbers held on my own base (1e14d70ae): PermissionProvider.tsx:124 over four useCallbacks, MePermissionsProvider.tsx:285 over six. The consumer chain re-measured on the same base: 9 dependency arrays across 6 files name the whole perms object — ListView (2, one of them the data-fetch effect), ObjectForm (3), RelatedList, DetailView, ModalForm, ObjectGrid. The 6 files match the census in #6819's pin header exactly; the array count differs from the 13 recorded there because this pattern matches single-line dependency closers only.

The pin FORCES a discard

packages/permissions/src/__tests__/providerCtxIdentity.discarded.test.tsx — a pin that does not force one proves nothing here, since React will not discard on its own. It reuses the module-level proxy technique from usePermissions.discardedIdentity.test.tsx (#6819) and ListView.discardedExpandFieldsMemo.test.tsx (#6697), with one difference that matters: it patches useCallback as well as useMemo, and discards EVERY armed cache rather than ones matched by a marker dependency — because the fix removes the dependency arrays altogether, so a marker-matched proxy would have nothing left to match and would go green for the trivial reason.

The first case proves the proxy really reaches the binding the providers use, and proves that arming alone does not break caching, so the greens below cannot be green for either trivial reason.

Reverse verification (ablation). Both provider files reverted to 1e14d70ae with the pin left in place, run from a committed state, mutation confirmed on disk by marker counts and blob hashes before measuring, restore confirmed by blob hash equality against the HEAD blobs and an empty git diff HEAD:

removed-marker createDiscardProofCache  P1=0 P2=0
restored-marker useMemo                 P1=2 P2=2
P1 899df91f -> b1c76c2a     P2 5e3d6070 -> 70f69df2
Tests  5 failed | 4 passed (9)

The 5 reds are exactly the identity cases (expected 3 to be 1 — three distinct context identities across two discards). The 4 that stay green in both states are the controls: the proxy canary, both genuine-change cases, and the answers-unchanged case.

Verification

Run on cee2b98 (the final commit), from the repo root — pnpm --filter <pkg> test is refused here by the objectui#3378 guard:

pnpm exec vitest run --maxWorkers=2 packages/permissions/      Test Files 8 passed (8) · Tests 85 passed (85)
pnpm --filter @object-ui/permissions type-check                exit 0  (tsc --noEmit && tsc -p tsconfig.test.json)
pnpm exec eslint packages/permissions --format json            18 files, 0 errors, 28 warnings (identical to base)

The typecheck was confirmed to actually cover the new files rather than excluding them: tsc -p tsconfig.test.json --listFiles names both discardProofCache.ts and the new pin.

Downstream consumers of the changed identity semantics — every file in the repo mounting either provider or PermCtx.Provider:

pnpm exec vitest run --maxWorkers=2 <8 consumer test files> packages/app-shell/src/console/home/__tests__/
   Test Files 20 passed (20) · Tests 126 passed (126)

Gates derived from this repo's own package.json and .github/workflows/ for the paths this diff actually touches (objectstack's dispatch-gates.mjs answers only about its own tree and was not used):

check:control-bytes         OK (5711 tracked text files scanned)
check:vi-mock-specifiers    OK (the new pin's vi.mock('react'))
check:esm-specifiers        OK (the new './discardProofCache.js' specifier)
check:self-import           OK
check:phantom-deps          OK
check:side-effects-array    OK
check:readme-exports        OK (after a full build; it collapses on an unbuilt tree)
check:eager-closure         OK (after a full build; a broken gauge without one)

Declared narrowing. The repo-wide pnpm lint was not run here; lint was measured on packages/permissions only, and the base-vs-head comparison above is what makes that a measurement rather than a gap. CI runs the full farm regardless.

check:published-dist is RED, and not from this branch: it reports one finding in @object-ui/fields that is present on origin/main and unrelated to packages/permissions. Filed as #6861. That gate carries no pull_request trigger by design, so it will not redden this PR — but it does block the release path.

Out-of-scope findings

Neither is touched by this PR.

Generated by Claude Code


Generated by Claude Code

…nnot discard it

Both permission providers built their context value in a `useMemo` over
`useCallback`s. Neither carries a semantic guarantee: React may discard the
cache and recompute even when the dependency list compares equal, and every
factory builds a fresh object, so a discard handed `PermCtx.Provider` a new
value with every permission it carries unchanged. That moves the key
`usePermissions()` caches on and re-runs the consumer chain that names it.

Hardening, not a repair: on the pinned React 19.2.8 the cache is not discarded
spontaneously and this repo has no `Activity`/Offscreen subtree.

Each member and each value is now keyed on the identities of the inputs it is
derived from, in a module-level `WeakMap` — the technique `usePermissions()`
already uses. Dependency sets are unchanged; no export or context shape changes.

Part of #6813

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions github-actions Bot added the tests label Aug 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 45 chunks) 3176.7 KB 3222.7 KB
Main entry chunk (gzip) 143.6 KB 350 KB
Entry file index-CID9KGim.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 (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 12.46KB 4.71KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
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) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
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.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
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) 511.68KB 116.36KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.17KB 47.98KB
fields (index.js) 243.30KB 61.51KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
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) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
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) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.92KB 12.93KB
plugin-charts (index.js) 64.68KB 18.35KB
plugin-chatbot (index.js) 190.53KB 45.18KB
plugin-dashboard (index.js) 133.48KB 34.51KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.43KB 62.46KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.32KB 32.69KB
plugin-gantt (index.js) 165.23KB 40.37KB
plugin-grid (index.js) 201.72KB 54.58KB
plugin-kanban (index.js) 53.14KB 14.64KB
plugin-list (index.js) 113.15KB 27.59KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 28.95KB 8.33KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.83KB 21.11KB
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) 4.47KB 1.63KB
react (SchemaRenderer.js) 76.75KB 25.49KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
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 (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
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) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 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 (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
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 (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
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

Merged via the queue into main with commit 30266cf Aug 30, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

census(finding): both permission providers build their context value in a useMemo, so a discard churns ctx identity for every usePermissions() consumer

0 participants