@object-ui/react 17.6.0 → 17.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +3766 -0
  2. package/README.md +294 -38
  3. package/dist/SchemaRenderer.d.ts +38 -7
  4. package/dist/SchemaRenderer.js +1218 -87
  5. package/dist/context/ActionContext.js +40 -1
  6. package/dist/context/DiscussionContext.d.ts +12 -0
  7. package/dist/context/DiscussionContext.js +2 -0
  8. package/dist/context/DrillNavigationContext.d.ts +13 -0
  9. package/dist/context/InlineEditContext.d.ts +81 -1
  10. package/dist/context/InlineEditContext.js +8 -2
  11. package/dist/context/RecordContext.d.ts +48 -5
  12. package/dist/context/RecordContext.js +10 -1
  13. package/dist/context/RelatedRecordActionsContext.d.ts +5 -1
  14. package/dist/context/SchemaRendererContext.d.ts +54 -2
  15. package/dist/context/SchemaRendererContext.js +38 -5
  16. package/dist/context/ThemeContext.js +1 -1
  17. package/dist/context/actionRunnerTranslator.d.ts +27 -0
  18. package/dist/context/actionRunnerTranslator.js +37 -0
  19. package/dist/element-data-source/ElementDataSourceGate.d.ts +88 -1
  20. package/dist/element-data-source/ElementDataSourceGate.js +326 -13
  21. package/dist/hooks/index.d.ts +3 -1
  22. package/dist/hooks/index.js +10 -1
  23. package/dist/hooks/useActionEngine.js +46 -3
  24. package/dist/hooks/useActionRunner.js +4 -0
  25. package/dist/hooks/useActionTextLocalizer.d.ts +21 -8
  26. package/dist/hooks/useActionTextLocalizer.js +33 -8
  27. package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
  28. package/dist/hooks/useConfigBagEvaluator.js +39 -0
  29. package/dist/hooks/useDensityMode.d.ts +6 -2
  30. package/dist/hooks/useDensityMode.js +6 -2
  31. package/dist/hooks/useDiscovery.d.ts +1 -10
  32. package/dist/hooks/useETagCache.js +25 -4
  33. package/dist/hooks/useElementDataSource.d.ts +15 -2
  34. package/dist/hooks/useElementDataSource.js +24 -7
  35. package/dist/hooks/useExpression.d.ts +52 -59
  36. package/dist/hooks/useExpression.js +58 -90
  37. package/dist/hooks/useFilterScope.d.ts +22 -2
  38. package/dist/hooks/useFilterScope.js +29 -4
  39. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  40. package/dist/hooks/useGlobalUndo.js +112 -46
  41. package/dist/hooks/useNavigationOverlay.d.ts +76 -11
  42. package/dist/hooks/useNavigationOverlay.js +119 -9
  43. package/dist/hooks/useOffline.js +65 -11
  44. package/dist/hooks/usePageAssignment.d.ts +8 -5
  45. package/dist/hooks/usePageAssignment.js +37 -16
  46. package/dist/hooks/useRecordSearch.d.ts +34 -1
  47. package/dist/hooks/useRecordSearch.js +42 -7
  48. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  49. package/dist/hooks/useResolvedFilter.js +130 -0
  50. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  51. package/dist/hooks/useSchemaPersistence.js +144 -5
  52. package/dist/hooks/useSettledSchema.d.ts +123 -0
  53. package/dist/hooks/useSettledSchema.js +186 -0
  54. package/dist/hooks/useViewData.d.ts +7 -2
  55. package/dist/index.d.ts +8 -1
  56. package/dist/index.js +45 -1
  57. package/dist/schema-input.d.ts +38 -8
  58. package/dist/schema-input.js +39 -7
  59. package/dist/utils/configBag.d.ts +68 -0
  60. package/dist/utils/configBag.js +70 -0
  61. package/dist/utils/error-message.d.ts +1 -1
  62. package/dist/utils/error-message.js +1 -1
  63. package/dist/utils/inlineAria.d.ts +61 -0
  64. package/dist/utils/inlineAria.js +70 -0
  65. package/dist/utils/nonGridRowCeiling.d.ts +44 -0
  66. package/dist/utils/nonGridRowCeiling.js +78 -0
  67. package/dist/utils/paramsBag.d.ts +28 -0
  68. package/dist/utils/paramsBag.js +146 -0
  69. package/dist/utils/propsBagDiagnostic.d.ts +196 -0
  70. package/dist/utils/propsBagDiagnostic.js +421 -0
  71. package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
  72. package/dist/utils/refusedDataPropDiagnostic.js +94 -0
  73. package/dist/utils/unevaluatedExpression.d.ts +42 -1
  74. package/dist/utils/unevaluatedExpression.js +74 -11
  75. package/dist/utils/visibilityDiagnostic.d.ts +271 -19
  76. package/dist/utils/visibilityDiagnostic.js +311 -44
  77. package/package.json +10 -10
  78. package/dist/hooks/useClientNotifications.d.ts +0 -47
  79. package/dist/hooks/useClientNotifications.js +0 -162
  80. package/dist/spec-bridge/SpecBridge.d.ts +0 -28
  81. package/dist/spec-bridge/SpecBridge.js +0 -56
  82. package/dist/spec-bridge/bridges/form-view.d.ts +0 -158
  83. package/dist/spec-bridge/bridges/form-view.js +0 -170
  84. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  85. package/dist/spec-bridge/bridges/list-view.js +0 -268
  86. package/dist/spec-bridge/index.d.ts +0 -11
  87. package/dist/spec-bridge/index.js +0 -10
  88. package/dist/spec-bridge/types.d.ts +0 -55
  89. package/dist/spec-bridge/types.js +0 -8
@@ -22,8 +22,9 @@
22
22
  * ```
23
23
  */
24
24
  import { useCallback, useContext, useMemo } from 'react';
25
- import { ActionEngine, } from '@object-ui/core';
25
+ import { ActionEngine, subjectPermissionsOf, } from '@object-ui/core';
26
26
  import { ActionCtxReact } from '../context/ActionContext.js';
27
+ import { usePredicateScope } from './useExpression.js';
27
28
  export function useActionEngine(options = {}) {
28
29
  const { actions = [], context = {} } = options;
29
30
  // When wrapped in an <ActionProvider>, reuse its ActionRunner so that:
@@ -39,7 +40,42 @@ export function useActionEngine(options = {}) {
39
40
  // fall back to a self-contained runner constructed from the `context` arg.
40
41
  const providerCtx = useContext(ActionCtxReact);
41
42
  const sharedRunner = providerCtx?.runner ?? null;
43
+ // The acting subject the host's predicate scope binds as `current_user`
44
+ // (objectui#11212): the ONE object `ExpressionProvider` publishes under
45
+ // `current_user` / `user` / `ctx.user` / `os.user`, carrying the caller's
46
+ // effective object permissions once they have loaded. The engine filters
47
+ // `visible` against the RUNNER's bag, which a host seeds with its own `user`
48
+ // and never with `current_user` — so `current_user.can(…)`, and any other
49
+ // `current_user.*` predicate, faulted on this path in every state and the
50
+ // action was hidden even for a grant holder, while the same predicate
51
+ // answered on every predicate-scope surface. Binding the scope's subject
52
+ // itself — the same object, not a copy, because the engine answers `can`
53
+ // only for a receiver IDENTICAL to the bound `current_user` — is what makes
54
+ // the two paths one bag (objectui#6493). With no host scope (`{}`) nothing
55
+ // is bound and the path behaves as it always did.
56
+ //
57
+ // Only `current_user` is added. The runner's `user` / `ctx.user` / `os.user`
58
+ // stay the host's own object: it carries what the runner itself reads (the
59
+ // `systemPermissions` capability set), which the scope's subject does not.
60
+ //
61
+ // Since objectui#11242 `<ActionProvider>` binds the same subject on its own
62
+ // runner, so under a provider mounted INSIDE the predicate scope this write
63
+ // re-binds the object that is already there. It stays for the two runners
64
+ // no provider binds: this hook's own standalone runner (no provider at
65
+ // all), and a shared runner whose provider sits ABOVE the scope — the
66
+ // console's global provider does (`GlobalActionRuntimeProvider` wraps the
67
+ // routes, and `ExpressionProvider` is mounted inside them) — where the
68
+ // provider has no scope to read.
69
+ const subject = usePredicateScope().current_user;
70
+ // Keyed on what the binding READS, never on the subject's identity (a
71
+ // memoised value upstream — AGENTS.md #10): its serialisable fields, and the
72
+ // permissions map it carries, which the upstream adapter caches per payload
73
+ // outside React. A discarded-and-recomputed subject with the same content
74
+ // keeps the engine; the permissions arriving (or leaving) rebuilds it.
75
+ const subjectKey = subject === undefined ? '' : JSON.stringify(subject);
76
+ const subjectPermissions = subjectPermissionsOf(subject);
42
77
  const engine = useMemo(() => {
78
+ const bound = subject !== undefined ? { current_user: subject } : {};
43
79
  // When standalone (no surrounding `<ActionProvider>`), normalize the
44
80
  // context so predicates can use both `record`/`user` and `ctx.*`.
45
81
  const normalizedStandalone = (context && Object.keys(context).length > 0)
@@ -48,8 +84,9 @@ export function useActionEngine(options = {}) {
48
84
  ctx: (context.ctx && typeof context.ctx === 'object')
49
85
  ? { ...context, ...context.ctx }
50
86
  : { ...context },
87
+ ...bound,
51
88
  }
52
- : context;
89
+ : { ...context, ...bound };
53
90
  const e = sharedRunner ? new ActionEngine(sharedRunner) : new ActionEngine(normalizedStandalone);
54
91
  // When sharing a provider runner, MERGE per-render flat keys into the
55
92
  // existing `ctx` instead of overwriting it. The provider seeds
@@ -67,13 +104,19 @@ export function useActionEngine(options = {}) {
67
104
  const merged = {
68
105
  ...context,
69
106
  ctx: { ...existingCtx, ...callerCtx },
107
+ ...bound,
70
108
  };
71
109
  runner.updateContext(merged);
72
110
  }
111
+ else if (sharedRunner && subject !== undefined) {
112
+ // A caller that passes no per-render keys: the subject is still bound,
113
+ // onto the same shared runner the location filter reads.
114
+ e.getRunner().updateContext(bound);
115
+ }
73
116
  e.registerActions(actions);
74
117
  return e;
75
118
  // eslint-disable-next-line react-hooks/exhaustive-deps
76
- }, [sharedRunner, JSON.stringify(actions), JSON.stringify(context)]);
119
+ }, [sharedRunner, JSON.stringify(actions), JSON.stringify(context), subjectKey, subjectPermissions]);
77
120
  const getActionsForLocation = useCallback((location) => engine.getActionsForLocation(location), [engine]);
78
121
  const getBulkActions = useCallback(() => engine.getBulkActions(), [engine]);
79
122
  const executeAction = useCallback((name, contextOverride) => engine.executeAction(name, contextOverride), [engine]);
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { useState, useCallback, useMemo } from 'react';
9
9
  import { ActionRunner, } from '@object-ui/core';
10
+ import { useActionRunnerTranslator } from '../context/actionRunnerTranslator.js';
10
11
  /**
11
12
  * Hook for executing actions with loading state, typed with ActionDef.
12
13
  *
@@ -35,8 +36,11 @@ export function useActionRunner(optionsOrContext = {}) {
35
36
  const [loading, setLoading] = useState(false);
36
37
  const [error, setError] = useState(null);
37
38
  const [result, setResult] = useState(null);
39
+ // The runner's own copy (the generic success toast) in the session's language.
40
+ const translate = useActionRunnerTranslator();
38
41
  const runner = useMemo(() => {
39
42
  const r = new ActionRunner(context);
43
+ r.setTranslator(translate);
40
44
  if (onConfirm)
41
45
  r.setConfirmHandler(onConfirm);
42
46
  if (onToast)
@@ -47,11 +47,23 @@
47
47
  * Translation localizes what exists; it does not add behaviour. This mirrors
48
48
  * what the already-correct call sites (`RecordDetailView`, `ObjectView`,
49
49
  * `DeclaredActionsBar`) each spelled out by hand.
50
- * - **`label` is reduced through `pickLocalized` first**, because since rc.6 an
51
- * authored `label` may be an `I18nLabel` map rather than a string. The map is
52
- * collapsed to the active language BEFORE it is offered to the bundle as the
53
- * fallback, so a per-locale literal and a bundle entry cannot disagree about
54
- * what "the authored label" is.
50
+ * - **`outcomeMessages` follows the same rule, entry by entry**
51
+ * (`@objectstack/spec` 17.6.0, objectui#11344): each outcome the action
52
+ * declares resolves `_actions.<name>.outcomeMessages.<outcome>`; a bundle
53
+ * entry for an outcome the action does not declare is never read, so a
54
+ * bundle cannot add copy for an answer the author never wrote one for.
55
+ * - **`label`, `successMessage` and each `outcomeMessages` entry are reduced
56
+ * through `pickLocalized` first**, because each is an `I18nLabel` — a string
57
+ * or an inline per-locale map. The map is collapsed to the active language
58
+ * BEFORE it is offered to the bundle as the fallback, so a per-locale
59
+ * literal and a bundle entry cannot disagree about what "the authored text"
60
+ * is, and the action runner, which has no language, only ever receives a
61
+ * string.
62
+ * - **The success copy stays a template.** A `${result.*}` token, in a bundle
63
+ * entry or in the authored text, passes through this hook untouched: the
64
+ * action has not run yet, so there is no result to read. The runner fills
65
+ * the token in after the action succeeds, through the same `${result.*}`
66
+ * scope `onSuccess.navigate` reads — one interpolation, in one place.
55
67
  */
56
68
  export interface ActionTextLocalizerOptions {
57
69
  /**
@@ -61,9 +73,10 @@ export interface ActionTextLocalizerOptions {
61
73
  fallbackLabel?: string;
62
74
  }
63
75
  /**
64
- * Returns a copy of `action` with `label` — plus `confirmText` and
65
- * `successMessage` when the action declares them — resolved from the active
66
- * locale's bundle, falling back to the authored literals.
76
+ * Returns a copy of `action` with `label` — plus `confirmText`,
77
+ * `successMessage` and `outcomeMessages` when the action declares them —
78
+ * resolved from the active locale's bundle, falling back to the authored
79
+ * literals.
67
80
  */
68
81
  export type ActionTextLocalizer = <T extends Record<string, any>>(objectName: string | undefined, action: T, options?: ActionTextLocalizerOptions) => T;
69
82
  export declare function useActionTextLocalizer(): ActionTextLocalizer;
@@ -47,16 +47,29 @@
47
47
  * Translation localizes what exists; it does not add behaviour. This mirrors
48
48
  * what the already-correct call sites (`RecordDetailView`, `ObjectView`,
49
49
  * `DeclaredActionsBar`) each spelled out by hand.
50
- * - **`label` is reduced through `pickLocalized` first**, because since rc.6 an
51
- * authored `label` may be an `I18nLabel` map rather than a string. The map is
52
- * collapsed to the active language BEFORE it is offered to the bundle as the
53
- * fallback, so a per-locale literal and a bundle entry cannot disagree about
54
- * what "the authored label" is.
50
+ * - **`outcomeMessages` follows the same rule, entry by entry**
51
+ * (`@objectstack/spec` 17.6.0, objectui#11344): each outcome the action
52
+ * declares resolves `_actions.<name>.outcomeMessages.<outcome>`; a bundle
53
+ * entry for an outcome the action does not declare is never read, so a
54
+ * bundle cannot add copy for an answer the author never wrote one for.
55
+ * - **`label`, `successMessage` and each `outcomeMessages` entry are reduced
56
+ * through `pickLocalized` first**, because each is an `I18nLabel` — a string
57
+ * or an inline per-locale map. The map is collapsed to the active language
58
+ * BEFORE it is offered to the bundle as the fallback, so a per-locale
59
+ * literal and a bundle entry cannot disagree about what "the authored text"
60
+ * is, and the action runner, which has no language, only ever receives a
61
+ * string.
62
+ * - **The success copy stays a template.** A `${result.*}` token, in a bundle
63
+ * entry or in the authored text, passes through this hook untouched: the
64
+ * action has not run yet, so there is no result to read. The runner fills
65
+ * the token in after the action succeeds, through the same `${result.*}`
66
+ * scope `onSuccess.navigate` reads — one interpolation, in one place.
55
67
  */
56
68
  import { useMemo } from 'react';
57
69
  import { useObjectLabel, useObjectTranslation, pickLocalized } from '@object-ui/i18n';
70
+ import { isConfigBag } from '../utils/configBag.js';
58
71
  export function useActionTextLocalizer() {
59
- const { actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
72
+ const { actionLabel, actionConfirm, actionSuccess, actionOutcome } = useObjectLabel();
60
73
  const { language } = useObjectTranslation();
61
74
  return useMemo(() => {
62
75
  return ((objectName, action, options) => {
@@ -83,9 +96,21 @@ export function useActionTextLocalizer() {
83
96
  out.confirmText = actionConfirm(objectName, name, action.confirmText);
84
97
  }
85
98
  if (action.successMessage !== undefined) {
86
- out.successMessage = actionSuccess(objectName, name, action.successMessage);
99
+ out.successMessage = actionSuccess(objectName, name, pickLocalized(action.successMessage, language));
100
+ }
101
+ // The map is an authored bag (outcome → copy), so the one "is this a
102
+ // real bag?" predicate in this package answers whether to read it.
103
+ const authoredOutcomes = action.outcomeMessages;
104
+ if (isConfigBag(authoredOutcomes)) {
105
+ const outcomeMessages = {};
106
+ for (const [outcome, copy] of Object.entries(authoredOutcomes)) {
107
+ const text = actionOutcome(objectName, name, outcome, pickLocalized(copy, language));
108
+ if (text !== undefined)
109
+ outcomeMessages[outcome] = text;
110
+ }
111
+ out.outcomeMessages = outcomeMessages;
87
112
  }
88
113
  return out;
89
114
  });
90
- }, [actionLabel, actionConfirm, actionSuccess, language]);
115
+ }, [actionLabel, actionConfirm, actionSuccess, actionOutcome, language]);
91
116
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * The `properties` evaluation of the `SchemaRenderer` memo, for a node that is
10
+ * rendered WITHOUT `SchemaRenderer` (objectui#10290).
11
+ *
12
+ * The action containers (`action:bar`, `action:group`, `action:menu` in
13
+ * `@object-ui/components`) draw their member actions themselves, so a member's
14
+ * `properties` never passes through the memo. Its static execution values ride
15
+ * `properties.params` (objectui#10289), and those values are templates
16
+ * evaluated where `properties` are (objectui#7867). This hook is that
17
+ * evaluation: the memo's per-key rule, its `ExpressionEvaluator`, and its
18
+ * scope (the host's ambient roots, `current_user`, the page's bound row as
19
+ * `record`, and page variables as `page`), read from the same contexts the memo
20
+ * reads. It is not a second template engine. See `evaluateConfigBagInScope`.
21
+ *
22
+ * The returned function takes a config bag and returns a new, evaluated bag. A
23
+ * value that is not a config bag is returned unchanged. Call it once per
24
+ * authored bag: the result is already evaluated, and evaluating it again would
25
+ * interpolate text that came out of the row.
26
+ *
27
+ * A fresh function each render. Nothing may depend on its identity
28
+ * (AGENTS.md #10).
29
+ */
30
+ export declare function useConfigBagEvaluator(): (bag: unknown) => unknown;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ import { useRecordContext } from '../context/RecordContext.js';
9
+ import { evaluateConfigBagInScope } from '../SchemaRenderer.js';
10
+ import { usePredicateScope } from './useExpression.js';
11
+ import { usePageVariables } from './usePageVariables.js';
12
+ /**
13
+ * The `properties` evaluation of the `SchemaRenderer` memo, for a node that is
14
+ * rendered WITHOUT `SchemaRenderer` (objectui#10290).
15
+ *
16
+ * The action containers (`action:bar`, `action:group`, `action:menu` in
17
+ * `@object-ui/components`) draw their member actions themselves, so a member's
18
+ * `properties` never passes through the memo. Its static execution values ride
19
+ * `properties.params` (objectui#10289), and those values are templates
20
+ * evaluated where `properties` are (objectui#7867). This hook is that
21
+ * evaluation: the memo's per-key rule, its `ExpressionEvaluator`, and its
22
+ * scope (the host's ambient roots, `current_user`, the page's bound row as
23
+ * `record`, and page variables as `page`), read from the same contexts the memo
24
+ * reads. It is not a second template engine. See `evaluateConfigBagInScope`.
25
+ *
26
+ * The returned function takes a config bag and returns a new, evaluated bag. A
27
+ * value that is not a config bag is returned unchanged. Call it once per
28
+ * authored bag: the result is already evaluated, and evaluating it again would
29
+ * interpolate text that came out of the row.
30
+ *
31
+ * A fresh function each render. Nothing may depend on its identity
32
+ * (AGENTS.md #10).
33
+ */
34
+ export function useConfigBagEvaluator() {
35
+ const predicateScope = usePredicateScope();
36
+ const boundRecord = useRecordContext()?.data;
37
+ const { variables: pageVariables } = usePageVariables();
38
+ return (bag) => evaluateConfigBagInScope(bag, predicateScope, boundRecord, pageVariables);
39
+ }
@@ -50,8 +50,12 @@ export interface DensityResult {
50
50
  *
51
51
  * @example
52
52
  * ```tsx
53
- * const density = useDensityMode(activeView?.densityMode ?? 'comfortable', {
54
- * onChange: (m) => dataSource.updateViewConfig(obj, vid, { densityMode: m }),
53
+ * import { DENSITY_MODE_TO_ROW_HEIGHT, rowHeightToDensityMode } from '@object-ui/core';
54
+ *
55
+ * // A view stores the protocol's `rowHeight`; the legacy `densityMode` key is
56
+ * // retired on `NamedListView` (objectui#7924), so persist the spec value.
57
+ * const density = useDensityMode(rowHeightToDensityMode(activeView?.rowHeight) ?? 'comfortable', {
58
+ * onChange: (m) => dataSource.updateViewConfig(obj, vid, { rowHeight: DENSITY_MODE_TO_ROW_HEIGHT[m] }),
55
59
  * });
56
60
  * ```
57
61
  */
@@ -35,8 +35,12 @@ const DENSITY_ORDER = ['compact', 'comfortable', 'spacious'];
35
35
  *
36
36
  * @example
37
37
  * ```tsx
38
- * const density = useDensityMode(activeView?.densityMode ?? 'comfortable', {
39
- * onChange: (m) => dataSource.updateViewConfig(obj, vid, { densityMode: m }),
38
+ * import { DENSITY_MODE_TO_ROW_HEIGHT, rowHeightToDensityMode } from '@object-ui/core';
39
+ *
40
+ * // A view stores the protocol's `rowHeight`; the legacy `densityMode` key is
41
+ * // retired on `NamedListView` (objectui#7924), so persist the spec value.
42
+ * const density = useDensityMode(rowHeightToDensityMode(activeView?.rowHeight) ?? 'comfortable', {
43
+ * onChange: (m) => dataSource.updateViewConfig(obj, vid, { rowHeight: DENSITY_MODE_TO_ROW_HEIGHT[m] }),
40
44
  * });
41
45
  * ```
42
46
  */
@@ -40,17 +40,8 @@ export interface DiscoveryInfo {
40
40
  /** Server name and version */
41
41
  name?: string;
42
42
  version?: string;
43
- /** Runtime mode (e.g., 'development', 'production', 'preview') */
43
+ /** Runtime mode the server reports (`RuntimeMode` in `@objectstack/spec`) */
44
44
  mode?: string;
45
- /** Preview mode configuration from the kernel (present when mode is 'preview') */
46
- previewMode?: {
47
- autoLogin?: boolean;
48
- simulatedRole?: 'admin' | 'user' | 'viewer';
49
- simulatedUserName?: string;
50
- readOnly?: boolean;
51
- expiresInSeconds?: number;
52
- bannerMessage?: string;
53
- };
54
45
  /** Service availability status */
55
46
  services?: {
56
47
  /** Authentication service status */
@@ -5,7 +5,7 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
+ import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState } from 'react';
9
9
  // ---------------------------------------------------------------------------
10
10
  // Defaults
11
11
  // ---------------------------------------------------------------------------
@@ -141,9 +141,30 @@ export function useETagCache(userConfig = {}) {
141
141
  const swUrl = userConfig.serviceWorkerUrl ?? DEFAULTS.serviceWorkerUrl;
142
142
  const [cacheSize, setCacheSize] = useState(0);
143
143
  const [serviceWorkerActive, setServiceWorkerActive] = useState(false);
144
- // Keep config in a ref so callbacks always see latest values
145
- const configRef = useRef({ enabled, storage, storagePrefix, maxEntries, ttl });
146
- configRef.current = { enabled, storage, storagePrefix, maxEntries, ttl };
144
+ // Keep config in a ref so callbacks always see latest values. Every reader
145
+ // below is a `useCallback` with `[]` deps whose identity consumers may key
146
+ // effects on, so the config has to reach them without changing it — that is
147
+ // the ref's job. Only the WRITE moves: done in the render body it also ran
148
+ // for renders React discards or replays, publishing config from a tree that
149
+ // never committed. `useInsertionEffect` runs in the mutation phase, ahead of
150
+ // every layout effect, ref attachment and paint, so the sole window deferred
151
+ // is the render phase — where `fetchWithETag` / `clearCache` and the other
152
+ // side effects are not callable anyway.
153
+ //
154
+ // The object itself is built by `useMemo` rather than inline at the two
155
+ // places that consume it. `useRef({ ... })` evaluates its argument on EVERY
156
+ // render and keeps only the first result, so every later render allocated a
157
+ // five-key object that was thrown away (objectui#6817) — the same shape PR
158
+ // objectui#6796 repaired in `useSchemaPersistence`. Its identity is
159
+ // unobservable from outside the hook: the ref is private and every reader
160
+ // only reads fields off `.current`, so a memo React chooses to discard is
161
+ // harmless — it rebuilds an equal object, which is what every render used
162
+ // to do unconditionally.
163
+ const config = useMemo(() => ({ enabled, storage, storagePrefix, maxEntries, ttl }), [enabled, storage, storagePrefix, maxEntries, ttl]);
164
+ const configRef = useRef(config);
165
+ useInsertionEffect(() => {
166
+ configRef.current = config;
167
+ });
147
168
  // Hydrate memory cache from localStorage on mount
148
169
  useEffect(() => {
149
170
  if (storage === 'localStorage') {
@@ -19,7 +19,7 @@
19
19
  * still ignores `view`), and the remaining object-bound blocks will need the same
20
20
  * resolution. One resolver in the runtime layer, not one per plugin.
21
21
  */
22
- import { type ComposedElementDataSource, type ElementDataSourceConfig, type ElementSavedView } from '@object-ui/core';
22
+ import { FilterOperatorError, type ComposedElementDataSource, type ElementDataSourceConfig, type ElementSavedView } from '@object-ui/core';
23
23
  /**
24
24
  * Where a resolution attempt stands. Distinguishing `loading` from `missing`
25
25
  * matters: a component that treated "not resolved yet" as "does not exist" would
@@ -34,7 +34,12 @@ export type ElementDataSourceStatus =
34
34
  | 'loading'
35
35
  /** The named `view` was found and composed in. */
36
36
  | 'resolved'
37
- /** The named `view` does not exist on the object (or could not be read). */
37
+ /**
38
+ * The binding cannot be applied: the named `view` does not exist on the
39
+ * object (or could not be read), or a filter it combines is REFUSED by the
40
+ * filter converter — then `filterRefusal` carries the refusal (objectui#10789).
41
+ * Either way `composed` is withheld, so no query runs without the filter.
42
+ */
38
43
  | 'missing';
39
44
  export interface UseElementDataSourceResult {
40
45
  status: ElementDataSourceStatus;
@@ -50,6 +55,14 @@ export interface UseElementDataSourceResult {
50
55
  view?: ElementSavedView;
51
56
  /** Author-facing explanation, set only for `missing`. */
52
57
  error?: string;
58
+ /**
59
+ * Why the binding could not be composed, when the reason is a refused
60
+ * filter — set only with `missing`, and `error` then carries its message
61
+ * (objectui#10789). A caller that draws the malformed-filter notice reads the
62
+ * refused operator or field off it (`filterRefusalSubject`) rather than
63
+ * scraping `error`.
64
+ */
65
+ filterRefusal?: FilterOperatorError;
53
66
  }
54
67
  /**
55
68
  * Resolve a schema node's `dataSource` binding, fetching the named saved view.
@@ -20,7 +20,7 @@
20
20
  * resolution. One resolver in the runtime layer, not one per plugin.
21
21
  */
22
22
  import { useContext, useEffect, useMemo, useState } from 'react';
23
- import { collectSavedViews, composeElementDataSource, elementDataSourceViewNotFoundMessage, isElementDataSourceConfig, resolveSavedView, } from '@object-ui/core';
23
+ import { collectSavedViews, composeElementDataSource, elementDataSourceViewNotFoundMessage, FilterOperatorError, isElementDataSourceConfig, resolveSavedView, } from '@object-ui/core';
24
24
  import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
25
25
  const isRecord = (v) => !!v && typeof v === 'object';
26
26
  /**
@@ -71,6 +71,9 @@ async function fetchSavedViews(dataSource, object) {
71
71
  * ```
72
72
  */
73
73
  export function useElementDataSource(schema, dataSource) {
74
+ // Read AS DECLARED (objectui#7209) — no cast between the hook and the value,
75
+ // so a member the context does not declare is a compile error here. Pinned by
76
+ // `useElementDataSource.schemaRendererContextRead-7209.test.ts`.
74
77
  const context = useContext(SchemaRendererContext);
75
78
  const adapter = (dataSource ?? context?.dataSource ?? null);
76
79
  const config = useMemo(() => {
@@ -131,11 +134,25 @@ export function useElementDataSource(schema, dataSource) {
131
134
  return { status: 'loading', config };
132
135
  if (resolved.view === null)
133
136
  return { status: 'missing', config, error: resolved.error };
134
- return {
135
- status: 'resolved',
136
- config,
137
- view: resolved.view,
138
- composed: composeElementDataSource(config, resolved.view),
139
- };
137
+ // objectui#10789 — composing the view's filter with the binding's own is a
138
+ // MERGE, and the merge lowers both through the throwing converter form.
139
+ // This is a render-time `useMemo`, so an uncaught refusal was a render
140
+ // error: the block's error boundary instead of a sentence naming the
141
+ // operator. The refusal is kept as a VALUE and reported as `missing`,
142
+ // which every caller already answers by withholding the query — never as
143
+ // `undefined`, which would read as "no filter" and widen the read. Only a
144
+ // `FilterOperatorError` is caught, the rule `toFilterNodeSafely` states:
145
+ // anything else is a defect, not a statement about the author's filter.
146
+ // (The `ready` branch above composes no view, so nothing is merged there.)
147
+ let composed;
148
+ try {
149
+ composed = composeElementDataSource(config, resolved.view);
150
+ }
151
+ catch (error) {
152
+ if (!(error instanceof FilterOperatorError))
153
+ throw error;
154
+ return { status: 'missing', config, error: error.message, filterRefusal: error };
155
+ }
156
+ return { status: 'resolved', config, view: resolved.view, composed };
140
157
  }, [config, resolved, requestKey]);
141
158
  }
@@ -60,56 +60,49 @@ export { toPredicateInput } from '@object-ui/core';
60
60
  * Build the predicate context for a **row record** — the bag to hand
61
61
  * {@link useCondition} when the thing being gated is scoped to one record.
62
62
  *
63
- * The row is bound the THREE ways the platform's row surfaces bind it:
64
- * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
65
- * `data.status` (legacy). This is not three dialects; it is one rule, and it is
66
- * `evalRowPredicate`'s rule (`core/evaluator/listConditional.ts` — the record
67
- * header, list rows, the row kebab and conditional formatting all evaluate
68
- * through it), restated here for the `useCondition` tier so both tiers answer
69
- * an author's `visible:` the same way.
70
- *
71
- * ## Why a helper and not "just spread the row" (objectui#4075)
72
- *
73
- * `useCondition` evaluates on `new ExpressionEvaluator({ ...scope, ...context })`,
74
- * so a caller that passes the row spread flat resolves the shorthand spelling
75
- * and NOTHING else. The canonical spelling is the `record.` root — it is what
76
- * `ExpressionEvaluator`'s CEL path binds (`bag.record` as the record
77
- * namespace), what `evalRowPredicate` binds, and what the server enforces
78
- * with. Under a root-only bag `record.viewer.can_act` does not read as `false`:
79
- * the evaluator throws `record is not defined`, and a fail-closed `visible`
80
- * turns that throw into "hidden". A correctly-authored predicate then deletes
81
- * its own button, indistinguishably from the gate having said no.
82
- *
83
- * That was live, not theoretical: every declared action on framework's
84
- * `sys_approval_request` gates on `record.viewer.*` (framework#3310 / #3424),
85
- * so the whole server-declared approval decision set was invisible wherever
86
- * `DeclaredActionsBar` rendered until objectui#4077 bound the row this way —
87
- * and the four generic action renderers carried the same root-only binding
88
- * until objectui#4075.
89
- *
90
- * ## The canon is `record.*` (objectui#5330, ruled 2026-08-20)
91
- *
92
- * All three spellings are bound here, and that is unchanged — but they are NOT
93
- * peers. `record.*` is the CONTRACT; the bare shorthand and `data.*` are
94
- * tolerances in a deprecation window, kept because stored metadata carries
95
- * them, reported once by `warnNonCanonicalRowSpelling` from `useCondition`
96
- * below, and removable only after a stored-metadata survey sizes the window
97
- * (⛔ no removal before the survey — part of the same ruling).
98
- *
99
- * The canon states the SERVER's accept set, which is narrower than this bag:
100
- * measured on `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts
101
- * exactly `['record']`, so of the three spellings this helper binds, only
102
- * `record.*` evaluates server-side — a bare field faults `Unknown variable:
103
- * status` and `data.*` faults `Unknown variable: data`. This three-way bag has
104
- * no server counterpart at all, which is exactly why the warning belongs on
105
- * this side. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
106
- * full measurement and the layer scoping (`data` stays canonical one layer
107
- * over, in a metadata-editing form — ADR-0089 D3).
108
- *
109
- * `record` and `data` are written AFTER the spread deliberately: a row that
110
- * happens to carry a field literally named `record` or `data` must not shadow
111
- * the root every predicate is written against. Same precedence as
112
- * `evalRowPredicate`.
63
+ * The row is bound ONE way: `record.status` — the canon (objectui#5330, ruled
64
+ * 2026-08-20; Phase 2 executed by `83fe6e741`). This is not a dialect choice;
65
+ * it is one rule, and it is `evalRowPredicate`'s rule
66
+ * (`core/evaluator/listConditional.ts` — the record header, list rows, the row
67
+ * kebab and conditional formatting all evaluate through it), restated here for
68
+ * the `useCondition` tier so both tiers answer an author's `visible:` the same
69
+ * way.
70
+ *
71
+ * ## The two retired spellings (`83fe6e741`)
72
+ *
73
+ * Until Phase 2 this bag also carried the row spread flat (bare `status`, the
74
+ * row-action shorthand) and as `data` (legacy), and `useCondition` below warned
75
+ * once per non-canonical spelling (Phase 1, `d1ab06f0f`). Both bindings and the
76
+ * warning are gone. A bare-field or `data.*` predicate against this bag now
77
+ * faults exactly as it always did on the server (`buildScope({ record })`
78
+ * mounts exactly `['record']`: `Unknown variable: status` / `Unknown variable:
79
+ * data`), and each `useCondition` leg applies its EXISTING fault policy — the
80
+ * throwing legs (`throwOnError`: every action `visible` — `action:button`,
81
+ * `action:menu`, `action:bar`, `DeclaredActionsBar`, and since objectui#11212
82
+ * `action:group`, `action:icon` and the related-list toolbar) hide and report
83
+ * `was hidden/disabled: its predicate threw`, naming the variable; the
84
+ * non-throwing legs fail soft to `true`. The same holds for a legacy `${data.x}` / `${x}` string: one bag
85
+ * shape, both dialects. Nothing detects a retired spelling here; it is simply
86
+ * unbound. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
87
+ * canon statement, the server measurement, the layer scoping (`data` stays
88
+ * canonical one layer over, in a metadata-editing form — ADR-0089 D3) and the
89
+ * offline detector.
90
+ *
91
+ * ## Why a helper and not "just `{ record }`" (objectui#4075 / #4080)
92
+ *
93
+ * Because the rule has two halves and every predicate face has to get both:
94
+ * the SHAPE — `record` is the row's one name, so `record.viewer.can_act`, what
95
+ * every declared action on framework's `sys_approval_request` gates on
96
+ * (framework#3310 / objectstack-ai/objectstack#3424), reaches the row — and the NO-ROW case below.
97
+ * `DeclaredActionsBar` and the four generic action renderers once carried
98
+ * inline copies of this bag and drifted (objectui#4077 / #4079); one named
99
+ * helper is what keeps a fifth copy from drifting again.
100
+ *
101
+ * Note the direction of the merge: `useCondition` evaluates on
102
+ * `new ExpressionEvaluator({ ...scope, ...context })`, so this bag's `record`
103
+ * shadows an ambient `record` a host put in the predicate scope — the row is
104
+ * the subject — while every OTHER ambient key (`features`, `user`, a host's own
105
+ * `data`) survives to the predicate as the host's own.
113
106
  *
114
107
  * This is the BINDING rule only. It deliberately does not touch the evaluation
115
108
  * entry — `useCondition` / `toPredicateInput` / `hasDeclaredVisibilityGate`
@@ -121,10 +114,10 @@ export { toPredicateInput } from '@object-ui/core';
121
114
  * ## No row → bind NOTHING, do not bind an empty one
122
115
  *
123
116
  * `null` / `undefined` / a non-object returns an EMPTY bag, not
124
- * `{ record: {}, data: {} }`. The difference is load-bearing: `useCondition`
125
- * merges this context OVER the ambient predicate scope, so binding an empty
126
- * record would blank out a `record` that a host had put in the scope itself —
127
- * which is exactly how `action:group`'s dropdown leaf is driven in
117
+ * `{ record: {} }`. The difference is load-bearing: `useCondition` merges this
118
+ * context OVER the ambient predicate scope, so binding an empty record would
119
+ * blank out a `record` that a host had put in the scope itself — which is
120
+ * exactly how `action:group`'s dropdown leaf is driven in
128
121
  * `action-group-dropdown-visible.test.tsx`, and it is a legitimate way for a
129
122
  * host to supply the row. "This surface has no row of its own" must stay
130
123
  * distinct from "this surface's row is empty"; only the latter is entitled to
@@ -159,11 +152,11 @@ export declare function useCondition(condition: string | boolean | undefined | {
159
152
  * routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
160
153
  * spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
161
154
  * envelope is always CEL, and only a legacy-dialect string falls back to the
162
- * old engine (with a deprecation warning). The row is bound as `record.*` — the
163
- * CANON (objectui#5330) — and, for stored metadata only, as bare fields and
164
- * `data.*`, both of which now warn once; the ambient predicate scope
165
- * (`features` / `user` / …) is merged alongside so deployment-level gates keep
166
- * resolving.
155
+ * old engine (with a deprecation warning). The row is bound as `record.*` only
156
+ * — the canon (objectui#5330; the bare-field and `data.*` spellings retired in
157
+ * `83fe6e741` and fault here like any unknown variable); the ambient
158
+ * predicate scope (`features` / `user` / …) is merged alongside so
159
+ * deployment-level gates keep resolving.
167
160
  *
168
161
  * @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
169
162
  * an `{ dialect, source }` envelope, or `null`/`undefined`/`''`.