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

perf(permissions): build both providers' context value where React cannot discard it#6863
os-sam merged 1 commit into
mainfrom
claude/issue-6813-permission-provider-ctx-identity

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#6813

Both permission providers built their context value in a useMemoPermissionProvider 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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3176.7 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-CID9KGim.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.68KB116.36KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)243.30KB61.51KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.43KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.72KB54.58KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.83KB21.11KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

@os-sam