') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); perf(permissions): build both providers' context value where React cannot discard it by claude[bot] · Pull Request #6863 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/tidy-permissions-provider-ctx-identity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/permissions': patch
---

Both permission providers now build their context value where React cannot discard it

`PermissionProvider` built its context value in a `useMemo` over four
`useCallback`s, and `MePermissionsProvider` in a `useMemo` over six. Neither
carries a semantic guarantee: React is permitted to discard the cache and
recompute even when the dependency list compares equal, and every one of those
factories builds a fresh object. A discard would therefore hand
`PermCtx.Provider` a NEW context value with every permission it carries
unchanged — which moves the key `usePermissions()` caches on, and re-runs the
consumer chain that names it: `ListView`'s data-fetch effect (an extra
`dataSource.find`), `DetailView`'s gatedSchema, `ObjectForm`, `ModalForm`,
`ObjectGrid` and `RelatedList`.

⚠️ This is **hardening, not a repair**. Nothing misbehaves today: 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 there is no `Activity`/Offscreen subtree here,
which is the documented case where React does throw memo caches away. What is
removed is the dependency on React continuing not to exercise a licence it
holds.

Each cached member and each context value is now keyed on the identities of the
inputs it is derived from, in a module-level `WeakMap` React has no say over —
the same technique that made `usePermissions()`'s own return discard-proof one
link down the chain. The dependency sets are unchanged, so nothing churns more
often than it did, and a genuine permission change still publishes a new
context value to every consumer. Two providers given the same inputs now share
one context value, which is stricter than the per-instance memo it replaces.

No published export changes, and the context carries exactly what it carried
before.
120 changes: 80 additions & 40 deletions packages/permissions/src/MePermissionsProvider.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/

import React, { useEffect, useMemo, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import {
HttpFetchError,
backoffMs,
Expand All@@ -20,6 +20,7 @@ import type {
FieldLevelPermission,
} from '@object-ui/types';
import { PermCtx, type PermissionContextValue } from './PermissionContext.js';
import { createDiscardProofCache } from './discardProofCache.js';

/**
* Shape of the upstream `/api/v1/auth/me/permissions` response.
Expand DownExpand Up@@ -99,6 +100,48 @@ export interface MePermissionsProviderProps {

const DEFAULT_ENDPOINT = '/api/v1/auth/me/permissions';

/**
* [objectui#6813] One cache per cached thing, each keyed on exactly the inputs
* that thing is derived from — the same sets the `useCallback`/`useMemo`
* dependency arrays named before, so nothing churns more often than it did.
* What changes is that React can no longer discard them: a discard used to
* hand `PermCtx.Provider` a NEW value with every permission it carries
* unchanged, which moves the key `usePermissions()` caches on (objectui#6724)
* and re-runs every consumer effect downstream. See `discardProofCache.ts` for
* why this is a module-level `WeakMap` and not a `useMemo` or a `useRef`.
*/
const CHECK = createDiscardProofCache<PermissionContextValue['check']>();
const CHECK_FIELD = createDiscardProofCache<PermissionContextValue['checkField']>();
const GET_FIELD_PERMISSIONS = createDiscardProofCache<PermissionContextValue['getFieldPermissions']>();
const GET_OBJECT_API_OPERATIONS = createDiscardProofCache<PermissionContextValue['getObjectApiOperations']>();
const VALUE = createDiscardProofCache<PermissionContextValue>();

/**
* Stands in for `data === null`, which cannot key a `WeakMap`. With no data
* every member answers its fail-closed constant and `isLoaded` is false, so
* this sentinel names exactly one reachable value rather than a family of them.
*/
const NO_DATA: object = { data: 'unloaded' };

/**
* `isLoaded` is a boolean and cannot key a `WeakMap` either. Its domain has two
* members, so two module-level sentinels cover it totally — no coercion, no
* collision. It is the ONLY thing `loading` and `error` contribute to the
* context value, so keying on it directly (rather than on `[data, loading,
* error]`) also stops a new `Error` identity from churning a value that reads
* the same to every consumer.
*/
const LOADED: object = { isLoaded: true };
const NOT_LOADED: object = { isLoaded: false };

/**
* The row filter this provider can offer for any object: none. `/me/permissions`
* carries no row-level filter, so this is a constant for every object and every
* provider instance — module-level, which is strictly stabler than the
* `useCallback(..., [])` it replaces, since React may discard that one.
*/
const NO_ROW_FILTER: PermissionContextValue['getRowFilter'] = () => undefined;

/**
* MePermissionsProvider
*
Expand DownExpand Up@@ -188,7 +231,9 @@ export function MePermissionsProvider({
return () => { token.cancelled = true; };
}, [fetchPermissions, initialPermissions]);

const checkField = useCallback(
const dataKey: object = data ?? NO_DATA;

const checkField = CHECK_FIELD([dataKey], () =>
(object: string, field: string, action: 'read' | 'write'): boolean => {
if (!data) return false; // fail-closed
// Normalize casing — backend stores keys lowercase but callers may
Expand DownExpand Up@@ -219,10 +264,9 @@ export function MePermissionsProvider({
? objPerm.allowRead !== false
: objPerm.allowEdit !== false;
},
[data],
);

const check = useCallback(
const check = CHECK([dataKey], () =>
(object: string, action: PermissionAction): PermissionCheckResult => {
if (!data) return { allowed: false, reason: 'permissions-loading' };
const objPerm = data.objects?.[object] ?? data.objects?.['*'];
Expand All@@ -245,10 +289,9 @@ export function MePermissionsProvider({
const allowed = objPerm ? (objPerm as any)[k] !== false : data.authenticated !== true;
return { allowed, reason: allowed ? undefined : 'denied-by-permission-set' };
},
[data],
);

const getFieldPermissions = useCallback(
const getFieldPermissions = GET_FIELD_PERMISSIONS([dataKey], () =>
(object: string): FieldLevelPermission[] => {
if (!data) return [];
const prefix = `${object}.`;
Expand All@@ -264,12 +307,9 @@ export function MePermissionsProvider({
}
return out;
},
[data],
);

const getRowFilter = useCallback(() => undefined, []);

const getObjectApiOperations = useCallback(
const getObjectApiOperations = GET_OBJECT_API_OPERATIONS([dataKey], () =>
(object: string): string[] | undefined => {
if (!data) return undefined;
const objKey = (object ?? '').toLowerCase();
Expand All@@ -279,38 +319,38 @@ export function MePermissionsProvider({
const ops = objPerm?.apiOperations;
return Array.isArray(ops) ? ops : undefined;
},
[data],
);

const value = useMemo<PermissionContextValue>(
() => ({
check,
checkField,
getFieldPermissions,
getRowFilter,
getObjectApiOperations,
roles: data?.roles ?? [],
// [objectui#5683] `null` while unloaded/anonymous — never ''. Consumers
// treat null as "unknown" and defer to the server.
userId: data?.userId ?? null,
// [objectui#4656] Forward the raw signal — do NOT `?? []` this. A
// backend predating ADR-0066 omits `systemPermissions` from the
// response entirely, and defaulting that to `[]` here made it
// indistinguishable from a genuinely empty grant to every consumer
// downstream (this provider's own `hasCapabilities` included).
systemPermissions: data?.systemPermissions,
hasCapabilities: (required: string[]) => {
const perms = data?.systemPermissions;
// Unknown (backend never reported systemPermissions) fails OPEN — see
// the doctrine on `PermissionContextValue.hasCapabilities`.
if (!Array.isArray(perms)) return true;
const held = new Set(perms);
return required.every((p) => held.has(p));
},
isLoaded: !loading && !error && data !== null,
}),
[check, checkField, getFieldPermissions, getRowFilter, getObjectApiOperations, data, loading, error],
);
const isLoaded = !loading && !error && data !== null;

// Keyed on the union of what the members above are keyed on, so this value is
// rebuilt exactly when one of them is and never captures a stale member.
const value = VALUE([dataKey, isLoaded ? LOADED : NOT_LOADED], () => ({
check,
checkField,
getFieldPermissions,
getRowFilter: NO_ROW_FILTER,
getObjectApiOperations,
roles: data?.roles ?? [],
// [objectui#5683] `null` while unloaded/anonymous — never ''. Consumers
// treat null as "unknown" and defer to the server.
userId: data?.userId ?? null,
// [objectui#4656] Forward the raw signal — do NOT `?? []` this. A
// backend predating ADR-0066 omits `systemPermissions` from the
// response entirely, and defaulting that to `[]` here made it
// indistinguishable from a genuinely empty grant to every consumer
// downstream (this provider's own `hasCapabilities` included).
systemPermissions: data?.systemPermissions,
hasCapabilities: (required: string[]) => {
const perms = data?.systemPermissions;
// Unknown (backend never reported systemPermissions) fails OPEN — see
// the doctrine on `PermissionContextValue.hasCapabilities`.
if (!Array.isArray(perms)) return true;
const held = new Set(perms);
return required.every((p) => held.has(p));
},
isLoaded,
}));

if (loading && !data) return <>{loadingFallback}</>;
if (error && !data) {
Expand Down
108 changes: 72 additions & 36 deletions packages/permissions/src/PermissionProvider.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/

import React, { useMemo, useCallback } from 'react';
import React from 'react';
import type {
RoleDefinition,
ObjectPermissionConfig,
Expand All@@ -16,6 +16,7 @@ import type {
} from '@object-ui/types';
import { PermCtx, type PermissionContextValue } from './PermissionContext.js';
import { evaluatePermission } from './evaluator.js';
import { createDiscardProofCache } from './discardProofCache.js';

export interface PermissionProviderProps {
/** Role definitions */
Expand All@@ -30,14 +31,61 @@ export interface PermissionProviderProps {
children: React.ReactNode;
}

/**
* [objectui#6813] One cache per cached thing, each keyed on exactly the inputs
* that thing is derived from — the same sets the `useCallback`/`useMemo`
* dependency arrays named before, so nothing churns more often than it did.
* What changes is that React can no longer discard them: a discard used to
* hand `PermCtx.Provider` a NEW value with every permission it carries
* unchanged, which moves the key `usePermissions()` caches on (objectui#6724)
* and re-runs every consumer effect downstream. See `discardProofCache.ts` for
* why this is a module-level `WeakMap` and not a `useMemo` or a `useRef`.
*/
const CHECK = createDiscardProofCache<PermissionContextValue['check']>();
const CHECK_FIELD = createDiscardProofCache<PermissionContextValue['checkField']>();
const GET_FIELD_PERMISSIONS = createDiscardProofCache<PermissionContextValue['getFieldPermissions']>();
const GET_ROW_FILTER = createDiscardProofCache<PermissionContextValue['getRowFilter']>();
const VALUE = createDiscardProofCache<PermissionContextValue>();

/**
* Stands in for an absent `user` prop, which is optional and therefore cannot
* key a `WeakMap` on its own. One module-level object, so "no user" is a
* stable identity rather than a hole in the key tuple.
*/
const NO_USER: object = { user: 'absent' };

/**
* [#3391] Role-based provider does not model the server's effective API
* operation set — return undefined so consumers keep current behavior.
*
* [objectui#6813] Module-level rather than a literal rebuilt inside the value
* factory: three consumers name this function in a dependency array
* (`RecordDetailView`, `ObjectDataPage`, `ObjectView`), and a constant that
* answers `undefined` for every object has nothing per-provider to close over.
*/
const NO_API_OPERATIONS: PermissionContextValue['getObjectApiOperations'] = () => undefined;

/**
* This role-based provider has no backend answer to give — it never fetches
* /me/permissions — so ADR-0066 system capabilities are simply unreported here
* (`systemPermissions: undefined` below, not `[]`; objectui#4656). A literal
* `[]` would claim "reported, holds nothing", which this provider cannot back
* up. `hasCapabilities` stays fail-open to match. The console uses
* MePermissionsProvider, which wires the real systemPermissions from
* /me/permissions.
*/
const ALL_CAPABILITIES: PermissionContextValue['hasCapabilities'] = () => true;

export function PermissionProvider({
roles,
permissions,
userRoles,
user,
children,
}: PermissionProviderProps) {
const check = useCallback(
const userKey = user ?? NO_USER;

const check = CHECK([roles, permissions, userRoles, userKey], () =>
(object: string, action: PermissionAction, record?: Record<string, unknown>): PermissionCheckResult => {
return evaluatePermission({
roles,
Expand All@@ -49,10 +97,9 @@ export function PermissionProvider({
record,
});
},
[roles, permissions, userRoles, user],
);

const checkField = useCallback(
const checkField = CHECK_FIELD([permissions, userRoles], () =>
(object: string, field: string, action: 'read' | 'write'): boolean => {
const objectConfig = permissions.find((p) => p.object === object);
if (!objectConfig) return true; // No config means no restrictions
Expand DownExpand Up@@ -81,10 +128,9 @@ export function PermissionProvider({

return true; // Default allow
},
[permissions, userRoles],
);

const getFieldPermissions = useCallback(
const getFieldPermissions = GET_FIELD_PERMISSIONS([permissions, userRoles], () =>
(object: string): FieldLevelPermission[] => {
const objectConfig = permissions.find((p) => p.object === object);
if (!objectConfig) return [];
Expand All@@ -100,10 +146,9 @@ export function PermissionProvider({
}
return fieldPerms;
},
[permissions, userRoles],
);

const getRowFilter = useCallback(
const getRowFilter = GET_ROW_FILTER([permissions, userRoles], () =>
(object: string): string | undefined => {
const objectConfig = permissions.find((p) => p.object === object);
if (!objectConfig) return undefined;
Expand All@@ -118,36 +163,27 @@ export function PermissionProvider({
}
return undefined;
},
[permissions, userRoles],
);

const value = useMemo<PermissionContextValue>(
() => ({
check,
checkField,
getFieldPermissions,
getRowFilter,
// [#3391] Role-based provider does not model the server's effective API
// operation set — return undefined so consumers keep current behavior.
getObjectApiOperations: () => undefined,
roles: userRoles,
// [objectui#5683] Role-based provider never learns who the user IS —
// unreported (`null`), so create-form current_user seeding stays
// server-side under this provider.
userId: null,
// This role-based provider has no backend answer to give — it never
// fetches /me/permissions — so ADR-0066 system capabilities are simply
// unreported here: `undefined`, not `[]` (objectui#4656; a literal `[]`
// would claim "reported, holds nothing", which this provider cannot
// back up). `hasCapabilities` stays fail-open to match. The console
// uses MePermissionsProvider, which wires the real systemPermissions
// from /me/permissions.
systemPermissions: undefined,
hasCapabilities: () => true,
isLoaded: true,
}),
[check, checkField, getFieldPermissions, getRowFilter, userRoles],
);
// Keyed on the union of what the members above are keyed on, so this value
// is rebuilt exactly when one of them is and never captures a stale member.
const value = VALUE([roles, permissions, userRoles, userKey], () => ({
check,
checkField,
getFieldPermissions,
getRowFilter,
getObjectApiOperations: NO_API_OPERATIONS,
roles: userRoles,
// [objectui#5683] Role-based provider never learns who the user IS —
// unreported (`null`), so create-form current_user seeding stays
// server-side under this provider.
userId: null,
// [objectui#4656] Unreported, not a reported-empty grant — see
// `ALL_CAPABILITIES` above for the full reasoning.
systemPermissions: undefined,
hasCapabilities: ALL_CAPABILITIES,
isLoaded: true,
}));

return <PermCtx.Provider value={value}>{children}</PermCtx.Provider>;
}
Loading
Loading