diff --git a/.changeset/10666-filter-token-sweep.md b/.changeset/10666-filter-token-sweep.md
new file mode 100644
index 0000000000..39f3445338
--- /dev/null
+++ b/.changeset/10666-filter-token-sweep.md
@@ -0,0 +1,33 @@
+---
+'@object-ui/react': minor
+'@object-ui/plugin-calendar': patch
+'@object-ui/plugin-gantt': patch
+'@object-ui/plugin-kanban': patch
+'@object-ui/plugin-map': patch
+'@object-ui/plugin-timeline': patch
+'@object-ui/plugin-tree': patch
+'@object-ui/plugin-detail': patch
+'@object-ui/plugin-form': patch
+'@object-ui/plugin-list': patch
+'@object-ui/components': patch
+'@object-ui/types': patch
+---
+
+Every data node that sends its own authored `filter` into a query now resolves the spec's context tokens first (objectui#10666). With `filter: [['owner', '=', '{current_user_id}']]` these nodes used to send the literal token; they now send the signed-in user's id, and `{current_org_id}` resolves to the active organization:
+
+- `object-calendar`, `object-map` and `object-tree`, on both the object query and the inline (`provider: 'value'`) query;
+- `object-gantt`, `object-kanban` and `object-timeline`;
+- `record:related_list` and `record:line_items`, where the node's own filter is combined with the parent-record condition;
+- the `element:repeater`, `element:number` (both the `aggregate` filter and the `find` fallback) and `element:record_picker` page elements.
+
+`list-view`, `object-grid` and `object-gallery` already did this, and are unchanged.
+
+**New in `@object-ui/react`: `useResolvedFilter(filter, scope)`.** Pass it a node's authored filter and `useFilterScope()`. It resolves the filter through `@object-ui/core`'s shared `resolveFilterPlaceholders` and holds the result: re-rendering with an equal filter (even one rebuilt inline on every render) hands back the same value, so a fetch effect keyed on it does not run again, while a structurally different filter or a change of signed-in user or organization resolves again. A filter with no token to resolve is handed back as the value you passed in. The hold is the one `list-view` and `object-gallery` used privately; `plugin-list` now imports it from here, and no second copy remains in that package.
+
+What changes for an app:
+
+- A filter with no placeholders reaches the query as before, unchanged.
+- Nodes that re-queried whenever the host rebuilt an equal filter inline (`object-calendar`, `object-gantt`, `object-kanban`, `object-map`, `object-tree`) no longer issue those redundant queries.
+- Date macros such as `{today}` in these nodes' own filters are now resolved in the browser's local time, as they already are for `list-view`, `object-grid` and `object-view`. Before, they reached the server as literals, and the server resolved them in the tenant's time zone.
+
+`@object-ui/types`: the `filter` docblocks on `ObjectMapSchema`, `ObjectTreeSchema`, `ObjectGanttSchema`, `ObjectCalendarSchema`, `ObjectKanbanSchema`, `ObjectChartSchema` and `ObjectGallerySchema` (and the matching zod descriptions) no longer say the filter is forwarded verbatim; they say its context tokens are resolved first. No type changes.
diff --git a/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx b/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx
new file mode 100644
index 0000000000..d387d2a1c0
--- /dev/null
+++ b/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx
@@ -0,0 +1,378 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#10666 — the family closure pin: EVERY data node that sends its own
+ * authored `filter` into a data-source query resolves the spec's context
+ * tokens (`{current_user_id}`, `{current_org_id}`) first, through
+ * `@object-ui/core`'s ONE shared `resolveFilterPlaceholders`, against the
+ * session scope the host provides (`FilterScopeProvider` / `useFilterScope`),
+ * and HOLDS the resolved value so that no query fires again on a re-render.
+ *
+ * One enumeration table, one row per node (and per query path where a node
+ * has two). Each row renders the node DIRECTLY AUTHORED, through the real
+ * `SchemaRenderer` and the node's own package registration, under a
+ * `FilterScopeProvider`, over a data source that records every query it is
+ * asked. The inline (`provider: 'value'`) paths query a `ValueDataSource`
+ * built inside the node, so those rows record its `find` instead.
+ *
+ * The four cells per row:
+ * - `{current_user_id}` goes out as the signed-in user id;
+ * - `{current_org_id}` goes out as the active organization id;
+ * - a token-free filter goes out unchanged: the query equals, member for
+ * member, the one the same node sends for the literal filter;
+ * - re-renders that rebuild an equal (token-carrying) filter add no query.
+ *
+ * `object-grid`, `list-view` and `object-gallery` are regression rows: they
+ * were fixed by objectui#10607 and by the first half of objectui#10666, and
+ * their own pins live in their packages.
+ *
+ * ## Why this file lives in `apps/console`
+ *
+ * It renders nodes from nine packages in one table, so it must live in a
+ * package that declares all of them (the objectui#4409 dependency-direction
+ * method). `app-shell` lacks `plugin-gantt`, `plugin-map`, `plugin-timeline`
+ * and `plugin-tree`; this app declares every one.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+// No WebGL in the test env: the stub the `plugin-map` tests use. This app does
+// not declare `react-map-gl`, so the bare specifier does not resolve from here;
+// the mock names the file `plugin-map`'s own `react-map-gl/maplibre` import
+// resolves to (its `exports['./maplibre'].import`), which is the module id the
+// mock has to match.
+vi.mock('../../../../packages/plugin-map/node_modules/react-map-gl/dist/maplibre.js', () => ({
+ default: ({ children }: { children?: React.ReactNode }) =>
{children}
,
+ Map: ({ children }: { children?: React.ReactNode }) => {children}
,
+ NavigationControl: () => ,
+ Marker: ({ children }: { children?: React.ReactNode }) => {children}
,
+ Popup: ({ children }: { children?: React.ReactNode }) => {children}
,
+}));
+import { render, act, cleanup, waitFor } from '@testing-library/react';
+import { ComponentRegistry, ValueDataSource } from '@object-ui/core';
+import {
+ AdapterCtx,
+ FilterScopeProvider,
+ RecordContextProvider,
+ SchemaRenderer,
+ SchemaRendererProvider,
+} from '@object-ui/react';
+// Each import registers its package's nodes, the way the console loads them.
+import '@object-ui/components';
+import '@object-ui/plugin-calendar';
+import '@object-ui/plugin-detail';
+import '@object-ui/plugin-form';
+import '@object-ui/plugin-gantt';
+import '@object-ui/plugin-grid';
+import '@object-ui/plugin-kanban';
+import '@object-ui/plugin-list';
+import '@object-ui/plugin-map';
+import '@object-ui/plugin-timeline';
+import '@object-ui/plugin-tree';
+
+const USER = 'usr_42';
+const ORG = 'org_7';
+const OBJECT = 'task';
+
+const ROW = {
+ id: '1',
+ name: 'Acme',
+ owner: USER,
+ org: ORG,
+ status: 'open',
+ parent_id: null,
+ account_id: 'ACC-1',
+ invoice: 'inv-1',
+ starts_at: '2026-09-01T09:00:00Z',
+ ends_at: '2026-09-01T10:00:00Z',
+ start: '2026-09-01',
+ end: '2026-09-02',
+ due: '2026-09-01',
+ lat: 10,
+ lng: 20,
+};
+
+const OBJECT_SCHEMA = {
+ name: OBJECT,
+ fields: {
+ name: { type: 'text' },
+ owner: { type: 'text' },
+ org: { type: 'text' },
+ status: { type: 'text' },
+ parent_id: { type: 'text' },
+ account_id: { type: 'text' },
+ invoice: { type: 'text' },
+ starts_at: { type: 'datetime' },
+ ends_at: { type: 'datetime' },
+ start: { type: 'date' },
+ end: { type: 'date' },
+ due: { type: 'date' },
+ lat: { type: 'number' },
+ lng: { type: 'number' },
+ },
+};
+
+/** One query the node issued: which call, and its whole parameter object. */
+interface Query {
+ via: 'find' | 'aggregate' | 'inline';
+ params: Record;
+}
+
+/** A data source (and adapter) that records every query against `OBJECT`. */
+function makeDataSource(opts: { aggregate: boolean }) {
+ const queries: Query[] = [];
+ const ds: Record = {
+ queries,
+ find: vi.fn(async (object: string, params?: Record) => {
+ if (object === OBJECT) queries.push({ via: 'find', params: params ?? {} });
+ return { data: [{ ...ROW }], total: 1 };
+ }),
+ findOne: vi.fn(async () => ({ ...ROW })),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ batchTransaction: vi.fn(async () => ({ data: [] })),
+ getObjectSchema: vi.fn(async () => OBJECT_SCHEMA),
+ };
+ if (opts.aggregate) {
+ ds.aggregate = vi.fn(async (object: string, params?: Record) => {
+ if (object === OBJECT) queries.push({ via: 'aggregate', params: params ?? {} });
+ return [{ count: 1 }];
+ });
+ }
+ return ds as Record & { queries: Query[] };
+}
+
+type Filter = unknown;
+type FilterShape = 'tuple' | 'rule';
+
+/** The filter in the member shape the node's contract takes. */
+function filterFor(shape: FilterShape, field: string, value: string): Filter {
+ return shape === 'tuple' ? [[field, '=', value]] : [{ field, operator: 'equals', value }];
+}
+
+interface NodeRow {
+ /** Row label: the node type, and the query path where a node has two. */
+ node: string;
+ shape: FilterShape;
+ /** The node schema carrying `filter` where this node reads it. */
+ withFilter: (filter: Filter) => Record;
+ /** Where the node's query is recorded. */
+ path: 'dataSource' | 'inline';
+ /** `element:number` aggregates when the adapter can; `false` forces the `find` fallback. */
+ aggregate?: boolean;
+ /** Mounted under a record (the parent a related list scopes to). */
+ record?: boolean;
+ /** Stub the child grid a `list-view` renders, so only the list's own query counts. */
+ stubChildGrid?: boolean;
+ /** Which recorded parameter carries the filter. */
+ filterParam: '$filter' | 'filter';
+}
+
+const top = (base: Record) => (filter: Filter) => ({ ...base, filter });
+const element = (base: { type: string; id: string; properties: Record }) => (filter: Filter) => ({
+ ...base,
+ properties: { ...base.properties, filter },
+});
+
+const INLINE = { provider: 'value', items: [{ ...ROW }] };
+
+const CALENDAR = { startDateField: 'starts_at', endDateField: 'ends_at', titleField: 'name' };
+const MAP = { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' };
+
+// ⭐ The enumeration. Every census node is a row; the three regression rows
+// close the table.
+const ROWS: NodeRow[] = [
+ { node: 'object-calendar (object)', shape: 'tuple', path: 'dataSource', filterParam: '$filter',
+ withFilter: top({ type: 'object-calendar', objectName: OBJECT, calendar: CALENDAR }) },
+ { node: 'object-calendar (inline)', shape: 'tuple', path: 'inline', filterParam: '$filter',
+ // `staticData` is the rung that wraps into `{ provider: 'value', items }` on
+ // this block (objectui#8348); its `data` row is the array arm.
+ withFilter: top({ type: 'object-calendar', calendar: CALENDAR, staticData: [{ ...ROW }] }) },
+ { node: 'object-gantt', shape: 'tuple', path: 'dataSource', filterParam: '$filter',
+ withFilter: top({ type: 'object-gantt', objectName: OBJECT, titleField: 'name', startDateField: 'start', endDateField: 'end' }) },
+ { node: 'object-kanban', shape: 'tuple', path: 'dataSource', filterParam: '$filter',
+ withFilter: top({ type: 'object-kanban', objectName: OBJECT, groupBy: 'status', columns: [{ id: 'open', title: 'Open' }] }) },
+ { node: 'object-map (object)', shape: 'tuple', path: 'dataSource', filterParam: '$filter',
+ withFilter: top({ type: 'object-map', objectName: OBJECT, map: MAP }) },
+ { node: 'object-map (inline)', shape: 'tuple', path: 'inline', filterParam: '$filter',
+ withFilter: top({ type: 'object-map', map: MAP, data: INLINE }) },
+ { node: 'object-timeline', shape: 'tuple', path: 'dataSource', filterParam: '$filter',
+ withFilter: top({ type: 'object-timeline', objectName: OBJECT, timeline: { titleField: 'name', startDateField: 'due' } }) },
+ { node: 'object-tree (object)', shape: 'tuple', path: 'dataSource', filterParam: '$filter',
+ withFilter: top({ type: 'object-tree', objectName: OBJECT, parentField: 'parent_id', labelField: 'name' }) },
+ { node: 'object-tree (inline)', shape: 'tuple', path: 'inline', filterParam: '$filter',
+ withFilter: top({ type: 'object-tree', parentField: 'parent_id', labelField: 'name', data: INLINE }) },
+ { node: 'record:related_list', shape: 'rule', path: 'dataSource', filterParam: '$filter', record: true,
+ withFilter: top({ type: 'record:related_list', objectName: OBJECT, relationshipField: 'account_id', columns: ['name'] }) },
+ { node: 'record:line_items', shape: 'rule', path: 'dataSource', filterParam: '$filter',
+ withFilter: top({
+ type: 'record:line_items', childObject: OBJECT, relationshipField: 'invoice', parentId: 'inv-1',
+ columns: [{ name: 'name', label: 'Name', type: 'text' }],
+ }) },
+ { node: 'element:repeater', shape: 'tuple', path: 'dataSource', filterParam: '$filter',
+ withFilter: element({ type: 'element:repeater', id: 'rep', properties: { object: OBJECT, fields: ['name'] } }) },
+ { node: 'element:number (aggregate)', shape: 'tuple', path: 'dataSource', filterParam: 'filter', aggregate: true,
+ withFilter: element({ type: 'element:number', id: 'num', properties: { object: OBJECT, aggregate: 'count' } }) },
+ { node: 'element:number (find fallback)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', aggregate: false,
+ withFilter: element({ type: 'element:number', id: 'num', properties: { object: OBJECT, aggregate: 'count' } }) },
+ { node: 'element:record_picker', shape: 'rule', path: 'dataSource', filterParam: '$filter',
+ withFilter: element({ type: 'element:record_picker', id: 'rp', properties: { object: OBJECT } }) },
+ // Regression rows (objectui#10607, objectui#10666 part 1).
+ { node: 'object-gallery (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter',
+ withFilter: top({ type: 'object-gallery', objectName: OBJECT, gallery: { titleField: 'name' } }) },
+ { node: 'object-grid (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter',
+ withFilter: top({ type: 'object-grid', objectName: OBJECT, columns: ['name'] }) },
+ { node: 'list-view (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', stubChildGrid: true,
+ withFilter: top({ type: 'list-view', objectName: OBJECT, columns: ['name'] }) },
+];
+
+type DS = ReturnType;
+
+interface HostHandle {
+ rerender: () => void;
+}
+
+/**
+ * Mounts one node. Every render builds the filter afresh from `makeFilter`,
+ * so a re-render hands the node an EQUAL filter at a NEW identity; the rest of
+ * the schema is rebuilt around it the same way on every pass.
+ */
+const Host = React.forwardRef Filter }>(
+ function Host({ row, ds, makeFilter }, ref) {
+ const [, setPass] = React.useState(0);
+ React.useImperativeHandle(ref, () => ({ rerender: () => setPass((n) => n + 1) }), []);
+ const node = ;
+ return (
+
+
+
+ {row.record ? (
+
+ {node}
+
+ ) : node}
+
+
+
+ );
+ },
+);
+
+/** Let every pending effect and resolved promise land. */
+async function settle() {
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 60));
+ });
+}
+
+let inlineQueries: Query[] = [];
+
+/** Mount a row and wait for its first query; returns the recorder and the host handle. */
+async function mount(row: NodeRow, makeFilter: () => Filter) {
+ const ds = makeDataSource({ aggregate: row.aggregate !== false });
+ const recorded = () => (row.path === 'inline' ? inlineQueries : ds.queries);
+ const ref = React.createRef();
+ render();
+ await waitFor(() => expect(recorded().length, `${row.node} issued no query`).toBeGreaterThan(0));
+ await settle();
+ return { recorded, ref };
+}
+
+/** The filter the node sent on its first recorded query. */
+function sentFilter(row: NodeRow, queries: Query[]) {
+ return queries[0].params[row.filterParam];
+}
+
+const STUBBED_GRID = () => ;
+let restoreGrid: (() => void) | null = null;
+
+beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ // Best-effort metadata probes are not what these cases are about.
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' })));
+ inlineQueries = [];
+ const realFind = ValueDataSource.prototype.find;
+ vi.spyOn(ValueDataSource.prototype, 'find').mockImplementation(function (this: ValueDataSource, resource, params) {
+ inlineQueries.push({ via: 'inline', params: (params ?? {}) as Record });
+ return realFind.call(this, resource, params);
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ restoreGrid?.();
+ restoreGrid = null;
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+function stubChildGridFor(row: NodeRow) {
+ if (!row.stubChildGrid) return;
+ const prev = ComponentRegistry.get('object-grid');
+ ComponentRegistry.register('object-grid', STUBBED_GRID);
+ restoreGrid = () => {
+ if (prev) ComponentRegistry.register('object-grid', prev as never);
+ };
+}
+
+describe.each(ROWS)('$node — its own filter reaches the query resolved (objectui#10666)', (row) => {
+ it('sends the signed-in user id for {current_user_id}, and the same query the literal id sends', async () => {
+ stubChildGridFor(row);
+ const token = await mount(row, () => filterFor(row.shape, 'owner', '{current_user_id}'));
+ const sent = JSON.stringify(sentFilter(row, token.recorded()));
+ expect(sent, `${row.node} sent the literal token`).not.toContain('{current_user_id}');
+ expect(sent).toContain(USER);
+ const tokenQuery = token.recorded()[0];
+ cleanup();
+ inlineQueries = [];
+
+ const literal = await mount(row, () => filterFor(row.shape, 'owner', USER));
+ expect(tokenQuery).toEqual(literal.recorded()[0]);
+ });
+
+ it('sends the active organization id for {current_org_id}', async () => {
+ stubChildGridFor(row);
+ const { recorded } = await mount(row, () => filterFor(row.shape, 'org', '{current_org_id}'));
+ const sent = JSON.stringify(sentFilter(row, recorded()));
+ expect(sent, `${row.node} sent the literal token`).not.toContain('{current_org_id}');
+ expect(sent).toContain(ORG);
+ });
+
+ it('CONTROL: a token-free filter reaches the query unchanged', async () => {
+ stubChildGridFor(row);
+ const authored = filterFor(row.shape, 'owner', 'usr_literal');
+ const { recorded } = await mount(row, () => filterFor(row.shape, 'owner', 'usr_literal'));
+ const sent = sentFilter(row, recorded());
+ if (row.shape === 'tuple') {
+ // A pass-through node: the members go out as authored.
+ expect(sent).toEqual(authored);
+ } else {
+ // A node that composes its own filter with the parent scope, or lowers
+ // rule objects first: the authored condition is inside what it sends.
+ expect(JSON.stringify(sent)).toContain('"usr_literal"');
+ expect(JSON.stringify(sent)).toContain('"owner"');
+ }
+ });
+
+ it('re-renders that rebuild an equal filter add no query', async () => {
+ stubChildGridFor(row);
+ const { recorded, ref } = await mount(row, () => filterFor(row.shape, 'owner', '{current_user_id}'));
+ const before = recorded().length;
+ for (let i = 0; i < 3; i++) {
+ await act(async () => {
+ ref.current!.rerender();
+ });
+ await settle();
+ }
+ expect(recorded().length, `${row.node} re-queried on a re-render that changed nothing`).toBe(before);
+ });
+});
diff --git a/packages/components/src/renderers/basic/data-list.tsx b/packages/components/src/renderers/basic/data-list.tsx
index 4fa38e3b62..5237dc2693 100644
--- a/packages/components/src/renderers/basic/data-list.tsx
+++ b/packages/components/src/renderers/basic/data-list.tsx
@@ -19,7 +19,7 @@
import * as React from 'react';
import { ComponentRegistry } from '@object-ui/core';
-import { useAdapter, useDataInvalidation } from '@object-ui/react';
+import { useAdapter, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react';
import { cn } from '../../lib/utils';
import { readProps } from './readProps';
@@ -110,7 +110,15 @@ function RepeaterRenderer({ schema }: { schema: any }) {
const [rows, setRows] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
- const filterKey = React.useMemo(() => (props.filter ? JSON.stringify(props.filter) : ''), [props.filter]);
+ // objectui#10666 — the repeater's own `filter`, with every context token
+ // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE
+ // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the
+ // session scope the host provides, and HELD by structure (`useResolvedFilter`
+ // in `@object-ui/react`). It sent the literal token on `$filter` before. The
+ // query and its content key below read THIS, never the raw `props.filter`.
+ const filterScope = useFilterScope();
+ const queryFilter = useResolvedFilter(props.filter, filterScope);
+ const filterKey = React.useMemo(() => (queryFilter ? JSON.stringify(queryFilter) : ''), [queryFilter]);
// objectui#10664 — the sort reaches `$orderby` below, so the fetch effect
// keys on it, by CONTENT the way `filterKey` keys the filter: a fresh array
// with the same entries is not a change (AGENTS.md #10).
@@ -141,7 +149,7 @@ function RepeaterRenderer({ schema }: { schema: any }) {
(async () => {
try {
const query: any = {};
- if (props.filter) query.$filter = props.filter;
+ if (queryFilter) query.$filter = queryFilter;
if (props.sort) query.$orderby = props.sort;
if (props.limit) query.$top = props.limit;
const res = await adapter.find(props.object, query);
diff --git a/packages/components/src/renderers/basic/elements.tsx b/packages/components/src/renderers/basic/elements.tsx
index a0611f7c51..71dfadc05a 100644
--- a/packages/components/src/renderers/basic/elements.tsx
+++ b/packages/components/src/renderers/basic/elements.tsx
@@ -27,7 +27,7 @@
import * as React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { ActionDef } from '@object-ui/core';
-import { useAdapter, useAction, useDataInvalidation } from '@object-ui/react';
+import { useAdapter, useAction, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react';
import {
useObjectTranslation,
pickLocalized,
@@ -389,7 +389,16 @@ function ElementNumberRenderer({ schema }: { schema: any }) {
const [value, setValue] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
- const filterKey = React.useMemo(() => (props.filter ? JSON.stringify(props.filter) : ''), [props.filter]);
+ // objectui#10666 — the number's own `filter`, with every context token
+ // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE
+ // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the
+ // session scope the host provides, and HELD by structure (`useResolvedFilter`
+ // in `@object-ui/react`). Both reads below (the `aggregate` filter and the
+ // `find` fallback's `$filter`) sent the literal token before; they and the
+ // content key read THIS, never the raw `props.filter`.
+ const filterScope = useFilterScope();
+ const queryFilter = useResolvedFilter(props.filter, filterScope);
+ const filterKey = React.useMemo(() => (queryFilter ? JSON.stringify(queryFilter) : ''), [queryFilter]);
// objectui#10623 — the data-invalidation bus (`notifyDataChanged` from
// `@object-ui/react`), read the objectui#10494 way: the nonce moves when a
// write to the object this number AGGREGATES is declared, and the effect
@@ -412,7 +421,7 @@ function ElementNumberRenderer({ schema }: { schema: any }) {
field: props.field,
function: props.aggregate,
groupBy: '_all',
- filter: props.filter,
+ filter: queryFilter,
});
const row = Array.isArray(rows) ? rows[0] : rows;
const measureKey = props.aggregate === 'count' ? 'count' : `${props.field ?? ''}_${props.aggregate}`;
@@ -425,7 +434,7 @@ function ElementNumberRenderer({ schema }: { schema: any }) {
} else if (typeof adapter.find === 'function') {
// Last-resort: pull all rows and aggregate client-side. Costly
// but matches the chart renderer fallback path.
- const res = await adapter.find(props.object, props.filter ? { $filter: props.filter } : undefined);
+ const res = await adapter.find(props.object, queryFilter ? { $filter: queryFilter } : undefined);
// `data` is the ONE rows member `QueryResult` (`@object-ui/types`)
// declares; the bare-array arm stays because fakes at this seam
// really do answer with a plain array. A `res?.records` arm sat
diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx
index b67a00c205..b5217ead2d 100644
--- a/packages/components/src/renderers/basic/record-picker.tsx
+++ b/packages/components/src/renderers/basic/record-picker.tsx
@@ -39,6 +39,8 @@ import {
useAdapter,
useElementDataSource,
usePageVariableBinding,
+ useFilterScope,
+ useResolvedFilter,
} from '@object-ui/react';
import { useObjectTranslation, pickLocalized } from '@object-ui/i18n';
import type { I18nLabel } from '@objectstack/spec/ui';
@@ -105,7 +107,15 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) {
// status panel instead.
const unresolved = dataBinding.status === 'loading' || dataBinding.status === 'missing';
const object = unresolved ? undefined : (composed?.object ?? props.object);
- const filter = composed?.filter ?? props.filter;
+ // objectui#10666 — whichever filter wins (the binding's, or the node's own),
+ // with every context token (`{current_user_id}`, `{current_org_id}`, the date
+ // macros) resolved ONCE through `@object-ui/core`'s shared
+ // `resolveFilterPlaceholders`, against the session scope the host provides,
+ // and HELD by structure (`useResolvedFilter` in `@object-ui/react`). The
+ // picker sent the literal token on `$filter` before; the query and its
+ // content key read this value.
+ const filterScope = useFilterScope();
+ const filter = useResolvedFilter(composed?.filter ?? props.filter, filterScope);
const sort = composed?.sort ?? props.sort;
const limit = composed?.limit ?? props.limit ?? 50;
const labelField = props.labelField ?? 'name';
diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx
index 2c804d022f..f6c1b69575 100644
--- a/packages/plugin-calendar/src/ObjectCalendar.tsx
+++ b/packages/plugin-calendar/src/ObjectCalendar.tsx
@@ -37,6 +37,8 @@ import {
useSettledSchema,
NonGridRowCeilingNote,
useDataInvalidation,
+ useFilterScope,
+ useResolvedFilter,
} from '@object-ui/react';
import {
RECORD_OVERLAY_DEFAULT_WIDTH,
@@ -600,6 +602,18 @@ export const ObjectCalendar: React.FC = ({
!hasExternalData && dataProvider === 'object' ? schemaObjectName || undefined : undefined,
);
+ // objectui#10666 — the node's own `filter`, with every context token
+ // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE
+ // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the
+ // session scope the host provides, and HELD by structure (`useResolvedFilter`
+ // in `@object-ui/react`). A directly authored calendar sent the literal token
+ // on `$filter` before. Both query paths below (the `object` fetch and the
+ // inline `ValueDataSource`) and the effect's dependency list read THIS, never
+ // the raw `schema.filter`, so a re-render that rebuilds an equal filter does
+ // not re-query.
+ const filterScope = useFilterScope();
+ const queryFilter = useResolvedFilter(schema.filter, filterScope);
+
// Fetch data based on provider
useEffect(() => {
// Skip internal fetch when data is managed by a parent component
@@ -666,7 +680,7 @@ export const ObjectCalendar: React.FC = ({
// `object` arm below resolves.
const inlineSource = new ValueDataSource({ items: (dataItems as any[]) ?? [] });
const result = await inlineSource.find('', {
- $filter: schema.filter,
+ $filter: queryFilter,
$orderby: convertSortToQueryParams(schema.sort),
// The same platform ceiling the `object` arm sends, on the same
// probe-row convention (objectui#7210, ruling a′). The ruling's
@@ -749,7 +763,7 @@ export const ObjectCalendar: React.FC = ({
? expandable
: expandable.filter((f) => perms.checkField(objectName, f, 'read'));
const result = await dataSource.find(objectName, {
- $filter: schema.filter,
+ $filter: queryFilter,
$orderby: convertSortToQueryParams(schema.sort),
// The platform ceiling (objectui#7210, ruling a′). A calendar
// still fetches the whole FILTERED set — it cannot lay out a month
@@ -799,7 +813,7 @@ export const ObjectCalendar: React.FC = ({
fetchData();
return () => { isMounted = false; };
}, [hasExternalData, dataProvider, schemaObjectName, dataItems, dataSource, hasInlineData,
- schema.filter, schema.sort, refreshKey, objectSchemaReady, objectSchema, perms, invalidationNonce]);
+ queryFilter, schema.sort, refreshKey, objectSchemaReady, objectSchema, perms, invalidationNonce]);
// Transform data to calendar events, and separate out the records that have
// no date to be placed on at all (objectui#7071 — see the early return in the
diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx
index ff53ae8861..f040a1f558 100644
--- a/packages/plugin-detail/src/RelatedList.tsx
+++ b/packages/plugin-detail/src/RelatedList.tsx
@@ -28,7 +28,7 @@ import {
resolveIcon,
useIsMobile,
} from '@object-ui/components';
-import { SchemaRenderer, useCapabilityGate, useCondition, toPredicateInput, useSettledSchema, type RelatedRowActionDef } from '@object-ui/react';
+import { SchemaRenderer, useCapabilityGate, useCondition, toPredicateInput, useSettledSchema, useFilterScope, useResolvedFilter, type RelatedRowActionDef } from '@object-ui/react';
import {
Plus,
ExternalLink,
@@ -651,9 +651,20 @@ export const RelatedList: React.FC = ({
// rendered below; it is deliberately NOT collapsed to `undefined`, which
// would mean "no filter" and run this list unconstrained — the silent
// widening objectui#9001 closed.
- const filterKey = JSON.stringify(filter ?? null);
+ //
+ // objectui#10666 — the list's own `filter` is lowered AFTER every context
+ // token in it (`{current_user_id}`, `{current_org_id}`, the date macros) is
+ // resolved ONCE through `@object-ui/core`'s shared
+ // `resolveFilterPlaceholders`, against the session scope the host provides,
+ // and HELD by structure (`useResolvedFilter` in `@object-ui/react`). The list
+ // merged the literal token into the parent scope before. The content key
+ // below is taken over the held value, so a new signed-in user moves it and
+ // re-queries, and a date macro such as `{now}` does not move it every render.
+ const filterScope = useFilterScope();
+ const scopeFilter = useResolvedFilter(filter, filterScope);
+ const filterKey = JSON.stringify(scopeFilter ?? null);
const listFilterResult = React.useMemo(
- () => toFilterNodeSafely(filter),
+ () => toFilterNodeSafely(scopeFilter),
// eslint-disable-next-line react-hooks/exhaustive-deps
[filterKey],
);
diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx
index 44b47677f2..df6919edd7 100644
--- a/packages/plugin-form/src/LineItemsPanel.tsx
+++ b/packages/plugin-form/src/LineItemsPanel.tsx
@@ -28,7 +28,7 @@ import {
} from '@object-ui/components';
import { LineItemsField, type GridColumn } from '@object-ui/fields';
import { createSafeTranslation } from '@object-ui/i18n';
-import { useSchemaContext, useRecordContext } from '@object-ui/react';
+import { useSchemaContext, useRecordContext, useFilterScope, useResolvedFilter } from '@object-ui/react';
import { usePermissions } from '@object-ui/permissions';
import { buildMasterDetailEditBatch, sumRows } from './masterDetailTx';
import { applyColumnPermissions } from './fieldWriteGate';
@@ -257,10 +257,21 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch
// refusal is kept as a VALUE and rendered below; collapsing it to
// `undefined` would mean "no filter" and load this panel's rows
// unconstrained, the silent widening objectui#9001 closed.
- const filterKey = JSON.stringify(schema.filter ?? null);
+ //
+ // objectui#10666 — the panel's own `filter` is lowered AFTER every context
+ // token in it (`{current_user_id}`, `{current_org_id}`, the date macros) is
+ // resolved ONCE through `@object-ui/core`'s shared
+ // `resolveFilterPlaceholders`, against the session scope the host provides,
+ // and HELD by structure (`useResolvedFilter` in `@object-ui/react`). The
+ // panel merged the literal token into the parent scope before. The content
+ // key is taken over the held value, so a new signed-in user moves it and
+ // reloads, and a date macro such as `{now}` does not move it every render.
+ const filterScope = useFilterScope();
+ const scopeFilter = useResolvedFilter(schema.filter, filterScope);
+ const filterKey = JSON.stringify(scopeFilter ?? null);
const sortKey = JSON.stringify(schema.sort ?? null);
const listFilterResult = useMemo(
- () => toFilterNodeSafely(schema.filter),
+ () => toFilterNodeSafely(scopeFilter),
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on CONTENT, see above
[filterKey],
);
diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx
index 79459f66bb..08438cf4c2 100644
--- a/packages/plugin-gantt/src/ObjectGantt.tsx
+++ b/packages/plugin-gantt/src/ObjectGantt.tsx
@@ -37,6 +37,8 @@ import {
SchemaRendererContext,
NonGridRowCeilingNote,
useDataInvalidation,
+ useFilterScope,
+ useResolvedFilter,
} from '@object-ui/react';
import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n';
import {
@@ -797,6 +799,16 @@ export const ObjectGantt: React.FC = ({
// Concurrent reloads are sequenced: only the newest request may commit its
// result, so a slow earlier response can't clobber a fresher one.
const [refreshing, setRefreshing] = useState(false);
+ // objectui#10666 — the node's own `filter`, with every context token
+ // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE
+ // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the
+ // session scope the host provides, and HELD by structure (`useResolvedFilter`
+ // in `@object-ui/react`). A directly authored gantt sent the literal token on
+ // `$filter` before. `reload` and both of its dependency lists read THIS,
+ // never the raw `schema.filter`, so a re-render that rebuilds an equal filter
+ // does not re-query.
+ const filterScope = useFilterScope();
+ const queryFilter = useResolvedFilter(schema.filter, filterScope);
const reloadSeqRef = useRef(0);
/**
* Has any reload committed rows yet? (objectui#7237, ruling A′)
@@ -908,7 +920,7 @@ export const ObjectGantt: React.FC = ({
? expandable
: expandable.filter((f) => perms.checkField(resource, f, 'read'));
const result = await adapter.find(resource, {
- $filter: schema.filter,
+ $filter: queryFilter,
$orderby: convertSortToQueryParams(schema.sort),
// The platform ceiling (objectui#7210, ruling a′). The gantt still
// fetches the whole FILTERED result set — a truthful
@@ -992,7 +1004,7 @@ export const ObjectGantt: React.FC = ({
// `ObjectGantt.discardedReloadIdentity-10036.test.tsx` hold the two in
// parity by exercising each entry.
// eslint-disable-next-line react-hooks/exhaustive-deps -- (rest as any).data intentionally untracked, matching the original effect
- }, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, schema.filter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms]);
+ }, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, queryFilter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms]);
/**
* Does the query this effect is about to issue DERIVE anything from the
@@ -1052,7 +1064,7 @@ export const ObjectGantt: React.FC = ({
useEffect(() => {
if (recordQueryDerivesExpand && !objectSchemaReady) return;
reloadRef.current(loadedOnceRef.current ? { inPlace: true } : {});
- }, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, schema.filter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms, recordQueryDerivesExpand, objectSchemaReady]);
+ }, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, queryFilter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms, recordQueryDerivesExpand, objectSchemaReady]);
/**
* objectui#10035 — the refresh input this gantt had none of, so a host could
diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx
index 210a3aa3a6..52688002f0 100644
--- a/packages/plugin-kanban/src/ObjectKanban.tsx
+++ b/packages/plugin-kanban/src/ObjectKanban.tsx
@@ -18,6 +18,8 @@ import {
declaredUserMessage,
useSettledSchema,
useDataInvalidation,
+ useFilterScope,
+ useResolvedFilter,
} from '@object-ui/react';
import {
NavigationOverlay,
@@ -561,6 +563,17 @@ export const ObjectKanban: React.FC = ({
// below re-keys on what it says, never on which array it is.
const sortKey = JSON.stringify(schema.sort ?? null);
+ // objectui#10666 — the node's own `filter`, with every context token
+ // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE
+ // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the
+ // session scope the host provides, and HELD by structure (`useResolvedFilter`
+ // in `@object-ui/react`). A directly authored board sent the literal token on
+ // `$filter` before. The fetch below and its dependency list read THIS, never
+ // the raw `schema.filter`, so a re-render that rebuilds an equal filter does
+ // not re-query.
+ const filterScope = useFilterScope();
+ const queryFilter = useResolvedFilter(schema.filter, filterScope);
+
// objectui#10572 — the data-invalidation bus (`notifyDataChanged` from
// `@object-ui/react`), read the objectui#10494 way: the nonce moves when a
// write to the object this board QUERIES is declared, and the fetch effect
@@ -690,7 +703,7 @@ export const ObjectKanban: React.FC = ({
// expression below; the pinned fact — `schema.limit` lowered into
// the query's top-level `$top` — is the same one it always held.
const query = {
- $filter: schema.filter,
+ $filter: queryFilter,
// objectui#10068 — the binding's `dataSource.sort` (or its
// view's), delivered here by `ElementDataSourceGate`. Lanes
// bucket records in fetch order, so this is also the in-lane
@@ -749,7 +762,7 @@ export const ObjectKanban: React.FC = ({
// `ObjectTimeline` spelling: an array rebuilt with the same members must not
// refetch the board.
// eslint-disable-next-line react-hooks/exhaustive-deps -- `schema.sort` is tracked by CONTENT (sortKey) on purpose; see above
- }, [schema.objectName, schemaKey, dataSource, boundData, schema.data, schema.filter, sortKey, schema.limit, hasExternalData, objectDefReady, objectDef, refreshKey, perms, invalidationNonce]);
+ }, [schema.objectName, schemaKey, dataSource, boundData, schema.data, queryFilter, sortKey, schema.limit, hasExternalData, objectDefReady, objectDef, refreshKey, perms, invalidationNonce]);
// Determine which data to use: external -> bound -> inline -> fetched
const rawData = (hasExternalData ? externalData : undefined) || boundData || schema.data || fetchedData;
diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx
index 014826f63d..dffb7bf592 100644
--- a/packages/plugin-list/src/ListView.tsx
+++ b/packages/plugin-list/src/ListView.tsx
@@ -15,14 +15,13 @@ import { VALUELESS_FILTER_BUILDER_OPERATORS, isFilterValueComplete } from '@obje
import { ViewSwitcherDropdown, ViewType } from './ViewSwitcher';
import { ViewSettingsPopover } from './components/ViewSettingsPopover';
import { UserFilters } from './UserFilters';
-import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope, useDataInvalidation, useFilterScope } from '@object-ui/react';
+import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react';
import type { LoadErrorKind } from '@object-ui/react';
import { useDensityMode } from '@object-ui/react';
import type { ListViewSchema, ObjectMapConfig } from '@object-ui/types';
import { detectStatusField } from '@object-ui/types';
import { usePullToRefresh } from '@object-ui/mobile';
import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveEffectiveCrudAffordances, isObjectInlineEditable, partitionRowsByPredicate, normalizeListViewSchema, isListViewVisualization, rowHeightToDensityMode, mergeFilterNodes, FilterOperatorError, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort } from '@object-ui/core';
-import { useResolvedAuthoredFilter } from './useResolvedAuthoredFilter';
import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale, pickLocalized } from '@object-ui/i18n';
// Two resolvers, two vocabularies — the repo spells the distinction into the
// NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own
@@ -1107,11 +1106,11 @@ export const ListView = React.forwardRef(({
// objectui#10607 — the node's own `filter`, with every placeholder resolved
// once against the host's session scope and held (see
- // `useResolvedAuthoredFilter`). The fetch, the page-reset signature, the
+ // `useResolvedFilter` in `@object-ui/react`). The fetch, the page-reset signature, the
// self-querying views, the child view's node, the export and the empty-state
// copy below all read THIS, never the raw `schema.filter`.
const filterScope = useFilterScope();
- const authoredFilter = useResolvedAuthoredFilter(schema.filter, filterScope);
+ const authoredFilter = useResolvedFilter(schema.filter, filterScope);
// Convenience: resolve field label with schema.objectName pre-bound
const tFieldLabel = React.useCallback(
diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx
index 55346a6dec..f01270a62f 100644
--- a/packages/plugin-list/src/ObjectGallery.tsx
+++ b/packages/plugin-list/src/ObjectGallery.tsx
@@ -7,14 +7,13 @@
*/
import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react';
-import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope } from '@object-ui/react';
+import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react';
import { ComponentRegistry, buildExpandFields, getRecordDisplayName, isEmptyValue } from '@object-ui/core';
import { cn, Card, CardContent, NavigationOverlay } from '@object-ui/components';
import { usePermissions } from '@object-ui/permissions';
import type { DataSource, GalleryConfig, ObjectGallerySchema, QueryParams } from '@object-ui/types';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { getCellRenderer, resolveCellRendererType, readFileValues } from '@object-ui/fields';
-import { useResolvedAuthoredFilter } from './useResolvedAuthoredFilter';
export interface ObjectGalleryProps {
/**
@@ -417,14 +416,14 @@ export const ObjectGallery: React.FC = (props) => {
// (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE
// through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against
// the session scope the host provides, and HELD by structure (see
- // `useResolvedAuthoredFilter`, the hold `ListView` has used since
- // objectui#10607). A directly authored gallery sent the literal token
+ // `useResolvedFilter` in `@object-ui/react`, the hold `ListView` has used
+ // since objectui#10607). A directly authored gallery sent the literal token
// before; a gallery rendered as a `list-view` child was already handed a
// resolved filter, and resolving it again changes nothing. The query and
// the fetch effect's dependency list below read THIS, never the raw
// `schema.filter`, so the effect does not re-run on every render.
const filterScope = useFilterScope();
- const authoredFilter = useResolvedAuthoredFilter(schema.filter, filterScope);
+ const authoredFilter = useResolvedFilter(schema.filter, filterScope);
useEffect(() => {
let isMounted = true;
diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx
index 3c59cd6219..fbede2de83 100644
--- a/packages/plugin-map/src/ObjectMap.tsx
+++ b/packages/plugin-map/src/ObjectMap.tsx
@@ -28,6 +28,8 @@ import {
NonGridRowCeilingNote,
useDataInvalidation,
useSettledSchema,
+ useFilterScope,
+ useResolvedFilter,
} from '@object-ui/react';
import { NavigationOverlay, cn, useIsMobile } from '@object-ui/components';
import { usePermissions } from '@object-ui/permissions';
@@ -843,6 +845,18 @@ export const ObjectMap: React.FC = ({
*/
const fetchSeqRef = useRef(0);
+ // objectui#10666 — the node's own `filter`, with every context token
+ // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE
+ // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the
+ // session scope the host provides, and HELD by structure (`useResolvedFilter`
+ // in `@object-ui/react`). A directly authored map sent the literal token on
+ // `$filter` before. Both query paths below (the `object` fetch and the inline
+ // `ValueDataSource`), the committed-query key and the effect's dependency
+ // list read THIS, never the raw `schema.filter`, so a re-render that
+ // rebuilds an equal filter does not re-query.
+ const filterScope = useFilterScope();
+ const queryFilter = useResolvedFilter(schema.filter, filterScope);
+
// Fetch data based on provider
useEffect(() => {
// ⭐ objectui#10664: the object definition GATES the object query; it does
@@ -867,7 +881,7 @@ export const ObjectMap: React.FC = ({
// nonce, the readiness flag and `fetchesForItself` (a function of two
// members already in it). Compared value for value (`Object.is`), the way
// React compares the list itself.
- const query = [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema, perms] as const;
+ const query = [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, queryFilter, schema.sort, objectSchema, perms] as const;
const committed = committedQueryRef.current;
const answersShownQuery = committed !== null && committed.length === query.length && query.every((v, i) => Object.is(v, committed[i]));
const nonceMoved = invalidationNonce !== answeredInvalidationRef.current;
@@ -935,7 +949,7 @@ export const ObjectMap: React.FC = ({
// `object` arm below resolves.
const inlineSource = new ValueDataSource({ items: (dataItems as any[]) ?? [] });
const result = await inlineSource.find('', {
- $filter: schema.filter,
+ $filter: queryFilter,
$orderby: convertSortToQueryParams(schema.sort),
// The same platform ceiling the `object` arm sends, on the same
// probe-row convention (objectui#7210, ruling a′). The ruling's
@@ -1014,7 +1028,7 @@ export const ObjectMap: React.FC = ({
? expandable
: expandable.filter((f) => perms.checkField(objectName, f, 'read'));
const result = await dataSource.find(objectName, {
- $filter: schema.filter,
+ $filter: queryFilter,
$orderby: convertSortToQueryParams(schema.sort),
// The platform ceiling (objectui#7210, ruling a′). A map still
// fetches the whole FILTERED set — the camera fit is computed from
@@ -1055,7 +1069,7 @@ export const ObjectMap: React.FC = ({
return () => {
fetchSeqRef.current += 1;
};
- }, [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, schema.filter, schema.sort, objectSchemaReady, objectSchema, perms, invalidationNonce, fetchesForItself]);
+ }, [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, queryFilter, schema.sort, objectSchemaReady, objectSchema, perms, invalidationNonce, fetchesForItself]);
// Transform data to map markers
const { markers, invalidCount } = useMemo(() => {
diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx
index 7705b9a3df..636a156314 100644
--- a/packages/plugin-timeline/src/ObjectTimeline.tsx
+++ b/packages/plugin-timeline/src/ObjectTimeline.tsx
@@ -8,7 +8,7 @@
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import type { DataSource, TimelineSchema, ListViewTimelineConfig } from '@object-ui/types';
-import { useDataScope, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation } from '@object-ui/react';
+import { useDataScope, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react';
import { NavigationOverlay } from '@object-ui/components';
import { extractRecords, buildExpandFields, convertSortToQueryParams, createFieldColorResolver, recordDisplayValueAt } from '@object-ui/core';
import { usePermissions } from '@object-ui/permissions';
@@ -349,7 +349,18 @@ export const ObjectTimeline: React.FC = ({
// (objectstack#7137), and an inline array on a schema node is a NEW object every
// render — depending on identity would refetch the whole object on every render.
// Same reason `RelatedList` keys its own scope filter on content.
- const filterKey = JSON.stringify(schema.filter ?? null);
+ //
+ // objectui#10666 — the key is taken over the node's own `filter` with every
+ // context token (`{current_user_id}`, `{current_org_id}`, the date macros)
+ // resolved ONCE through `@object-ui/core`'s shared `resolveFilterPlaceholders`,
+ // against the session scope the host provides, and HELD by structure
+ // (`useResolvedFilter` in `@object-ui/react`). A directly authored timeline
+ // sent the literal token on `$filter` before. The query below reads the held
+ // value, so a new signed-in user moves the key and re-queries, and a date
+ // macro such as `{now}` does not move it on every render.
+ const filterScope = useFilterScope();
+ const queryFilter = useResolvedFilter(schema.filter, filterScope);
+ const filterKey = JSON.stringify(queryFilter ?? null);
const sortKey = JSON.stringify(schema.sort ?? null);
// objectui#10623 — the data-invalidation bus (`notifyDataChanged` from
@@ -426,7 +437,7 @@ export const ObjectTimeline: React.FC = ({
// the view it named. `filter` arrives already AND-composed by
// `ElementDataSourceGate`, so there is nothing to merge here.
const results = await dataSource.find(schema.objectName, {
- $filter: schema.filter,
+ $filter: queryFilter,
$orderby: convertSortToQueryParams(schema.sort),
$top: resolveRowLimit(schema.limit, DEFAULT_TIMELINE_LIMIT),
...(expand.length > 0 ? { $expand: expand } : {}),
@@ -486,7 +497,7 @@ export const ObjectTimeline: React.FC = ({
// Have inline / bound items — won't fetch; clear loading.
setLoading(false);
}
- // eslint-disable-next-line react-hooks/exhaustive-deps -- `schema.filter`/`schema.sort` are tracked by CONTENT (filterKey/sortKey) on purpose; see above
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `queryFilter`/`schema.sort` are tracked by CONTENT (filterKey/sortKey) on purpose; see above
}, [schema.objectName, dataSource, boundData, schema.items, (props as any).data, refreshKey, objectDefReady, objectDef, filterKey, sortKey, schema.limit, perms, invalidationNonce]);
const rawData = (props as any).data || boundData || fetchedData;
diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx
index 9bd4058991..643b00ff3f 100644
--- a/packages/plugin-tree/src/ObjectTree.tsx
+++ b/packages/plugin-tree/src/ObjectTree.tsx
@@ -47,6 +47,8 @@ import {
useSafeFieldLabel,
useSettledSchema,
NonGridRowCeilingNote,
+ useFilterScope,
+ useResolvedFilter,
} from '@object-ui/react';
import {
NavigationOverlay,
@@ -683,6 +685,18 @@ export const ObjectTree: React.FC = ({
// PR #7428 recorded for `ListView`'s memo and `ObjectCalendar`'s effect).
const perms = usePermissions();
+ // objectui#10666 — the node's own `filter`, with every context token
+ // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE
+ // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the
+ // session scope the host provides, and HELD by structure (`useResolvedFilter`
+ // in `@object-ui/react`). A directly authored tree sent the literal token on
+ // `$filter` before. Both query paths below (the `object` fetch and the inline
+ // `ValueDataSource`) and the effect's dependency list read THIS, never the
+ // raw `schema.filter`, so a re-render that rebuilds an equal filter does not
+ // re-query.
+ const filterScope = useFilterScope();
+ const queryFilter = useResolvedFilter(schema.filter, filterScope);
+
// Fetch records.
useEffect(() => {
let cancelled = false;
@@ -748,7 +762,7 @@ export const ObjectTree: React.FC = ({
// discriminated union — same narrowing the pre-refactor
// `dataConfig.object` read carried.
const result = await dataSource.find(dataObjectName as string, {
- $filter: schema.filter,
+ $filter: queryFilter,
// The platform ceiling (objectui#7210, ruling a′). A tree still
// fetches the whole FILTERED set — a hierarchy assembled from a
// page loses every child whose parent fell outside it, which is
@@ -832,7 +846,7 @@ export const ObjectTree: React.FC = ({
// array — so this branch needs no object name.
const inlineSource = new ValueDataSource({ items: (dataItems as any[]) ?? [] });
const result = await inlineSource.find('', {
- $filter: schema.filter,
+ $filter: queryFilter,
// The same platform ceiling the `object` arm sends, on the same
// probe-row convention (objectui#7210, ruling a′). This is the view
// the ceiling's VALUE was measured on — ~5.2 DOM elements per
@@ -870,7 +884,7 @@ export const ObjectTree: React.FC = ({
return () => {
cancelled = true;
};
- }, [dataProvider, dataObjectName, dataItems, dataSource, schema.filter, objectSchema, schemaSettled, (rest as any).data, perms]);
+ }, [dataProvider, dataObjectName, dataItems, dataSource, queryFilter, objectSchema, schemaSettled, (rest as any).data, perms]);
const config = useMemo(() => getTreeConfig(schema), [schema]);
const parentField = useMemo(
diff --git a/packages/react/README.md b/packages/react/README.md
index 0caab1dbef..2794effe30 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -278,6 +278,45 @@ Pass `dataSource: undefined` for a render that should settle immediately with
no definition (e.g. a provider that issues no metadata read at all) instead of
adding a separate enable flag.
+### useFilterScope / useResolvedFilter
+
+A data node that sends its own authored `filter` into a query resolves the
+spec's context tokens in it first: `{current_user_id}`, `{current_org_id}` and
+the date macros such as `{today}` (objectui#10666). `useFilterScope()` reads
+the session scope a host mounts with `FilterScopeProvider`;
+`useResolvedFilter(filter, scope)` resolves the filter through
+`@object-ui/core`'s `resolveFilterPlaceholders` and HOLDS the result.
+
+```tsx
+import { useEffect } from 'react'
+import { useFilterScope, useResolvedFilter } from '@object-ui/react'
+import type { DataSource, QueryParams } from '@object-ui/types'
+
+function ObjectSomething({
+ schema,
+ dataSource,
+}: {
+ schema: { objectName: string; filter?: QueryParams['$filter'] }
+ dataSource: DataSource
+}) {
+ const filterScope = useFilterScope()
+ const filter = useResolvedFilter(schema.filter, filterScope)
+
+ useEffect(() => {
+ // Query with the held value and key the effect on it, never on schema.filter.
+ void dataSource.find(schema.objectName, { $filter: filter })
+ }, [dataSource, schema.objectName, filter])
+}
+```
+
+The held value keeps its reference while the authored filter is structurally
+equal (a filter rebuilt inline on every render included) and the scope's user,
+organization and `onUnresolved` are unchanged, so the effect above runs once.
+A structurally different filter, or a new signed-in user or organization,
+resolves again. A filter with no token to resolve is handed back as the value
+passed in. A token the scope cannot resolve is left in place, and the resolver
+logs one warning naming it.
+
### NON_GRID_ROW_CEILING
The platform's hard row ceiling for a NON-GRID visualisation — gantt, calendar,
diff --git a/packages/react/src/hooks/__tests__/useResolvedFilter.test.tsx b/packages/react/src/hooks/__tests__/useResolvedFilter.test.tsx
new file mode 100644
index 0000000000..995894b6f4
--- /dev/null
+++ b/packages/react/src/hooks/__tests__/useResolvedFilter.test.tsx
@@ -0,0 +1,113 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#10666 — `useResolvedFilter`, the ONE hold every data node resolves
+ * its own authored `filter` through. It resolves with `@object-ui/core`'s
+ * shared `resolveFilterPlaceholders` and HOLDS the result: the value keeps its
+ * reference while the authored filter (compared by structure) and the scope's
+ * three members are unchanged, and is resolved again when any of them moves.
+ * The node-level pins (every directly authored data node, through the real
+ * `SchemaRenderer`) are `apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx`.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { renderHook, cleanup } from '@testing-library/react';
+import type { FilterTokenScope } from '@object-ui/core';
+import { useResolvedFilter } from '../useResolvedFilter';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const SCOPE: FilterTokenScope = { currentUserId: 'usr_42', currentOrgId: 'org_7' };
+
+function hold(initial: { filter: unknown; scope: FilterTokenScope }) {
+ return renderHook(({ filter, scope }) => useResolvedFilter(filter, scope), { initialProps: initial });
+}
+
+describe('useResolvedFilter (objectui#10666)', () => {
+ it('resolves {current_user_id} and {current_org_id} against the scope', () => {
+ const { result } = hold({
+ filter: [['owner', '=', '{current_user_id}'], ['org', '=', '{current_org_id}']],
+ scope: SCOPE,
+ });
+ expect(result.current).toEqual([['owner', '=', 'usr_42'], ['org', '=', 'org_7']]);
+ });
+
+ it('keeps the same reference across re-renders with the same filter', () => {
+ const filter = [['owner', '=', '{current_user_id}']];
+ const { result, rerender } = hold({ filter, scope: SCOPE });
+ const first = result.current;
+ rerender({ filter, scope: SCOPE });
+ rerender({ filter, scope: { ...SCOPE } });
+ expect(result.current).toBe(first);
+ });
+
+ it('keeps the same reference when an EQUAL filter is rebuilt on every render', () => {
+ const { result, rerender } = hold({ filter: [['owner', '=', '{current_user_id}']], scope: SCOPE });
+ const first = result.current;
+ rerender({ filter: [['owner', '=', '{current_user_id}']], scope: SCOPE });
+ rerender({ filter: [['owner', '=', '{current_user_id}']], scope: SCOPE });
+ expect(result.current).toBe(first);
+ });
+
+ it('holds a date macro: {now} does not resolve to a new value on every render', () => {
+ const { result, rerender } = hold({ filter: [['created', '<', '{now}']], scope: SCOPE });
+ const first = result.current;
+ const sent = JSON.stringify(first);
+ expect(sent).not.toContain('{now}');
+ rerender({ filter: [['created', '<', '{now}']], scope: SCOPE });
+ expect(result.current).toBe(first);
+ });
+
+ it('resolves again when the filter changes by structure', () => {
+ const { result, rerender } = hold({ filter: [['owner', '=', '{current_user_id}']], scope: SCOPE });
+ const first = result.current;
+ rerender({ filter: [['owner', '!=', '{current_user_id}']], scope: SCOPE });
+ expect(result.current).not.toBe(first);
+ expect(result.current).toEqual([['owner', '!=', 'usr_42']]);
+ });
+
+ it('resolves again when a scope member changes', () => {
+ const { result, rerender } = hold({ filter: [['owner', '=', '{current_user_id}']], scope: SCOPE });
+ rerender({ filter: [['owner', '=', '{current_user_id}']], scope: { ...SCOPE, currentUserId: 'usr_99' } });
+ expect(result.current).toEqual([['owner', '=', 'usr_99']]);
+ rerender({ filter: [['org', '=', '{current_org_id}']], scope: { ...SCOPE, currentOrgId: 'org_8' } });
+ expect(result.current).toEqual([['org', '=', 'org_8']]);
+ });
+
+ it('CONTROL: a token-free filter comes back as the authored value itself', () => {
+ const filter = [['owner', '=', 'usr_literal']];
+ const { result, rerender } = hold({ filter, scope: SCOPE });
+ expect(result.current).toBe(filter);
+ // An equal rebuild keeps handing out the value first held.
+ rerender({ filter: [['owner', '=', 'usr_literal']], scope: SCOPE });
+ expect(result.current).toBe(filter);
+ });
+
+ it('a filter the resolver changes comes back as a new value, not the authored one', () => {
+ const filter = [['owner', '=', '{current_user_id}']];
+ const { result } = hold({ filter, scope: SCOPE });
+ expect(result.current).not.toBe(filter);
+ expect(filter).toEqual([['owner', '=', '{current_user_id}']]);
+ });
+
+ it('passes an absent filter through', () => {
+ const { result } = hold({ filter: undefined, scope: SCOPE });
+ expect(result.current).toBeUndefined();
+ });
+
+ it('leaves a token the scope cannot resolve intact, and the shared resolver names it (no fallback)', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const { result } = hold({ filter: [['owner', '=', '{current_user_id}']], scope: {} });
+ expect(result.current).toEqual([['owner', '=', '{current_user_id}']]);
+ expect(warn.mock.calls.some((c) => String(c[0]).includes('{current_user_id}'))).toBe(true);
+ });
+});
diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts
index 979a1a0d0a..93b35397da 100644
--- a/packages/react/src/hooks/index.ts
+++ b/packages/react/src/hooks/index.ts
@@ -9,6 +9,9 @@
export * from './useExpression.js';
// Session scope for filter placeholders ({current_user_id}/{current_org_id}).
export * from './useFilterScope.js';
+// The ONE hold a data node resolves its own authored `filter` through
+// (objectui#10666), fed by `useFilterScope()`.
+export * from './useResolvedFilter.js';
export * from './useActionRunner.js';
export * from './useNavigationOverlay.js';
export * from './usePageVariables.js';
diff --git a/packages/plugin-list/src/useResolvedAuthoredFilter.ts b/packages/react/src/hooks/useResolvedFilter.ts
similarity index 52%
rename from packages/plugin-list/src/useResolvedAuthoredFilter.ts
rename to packages/react/src/hooks/useResolvedFilter.ts
index 9d7fa15a41..3723744620 100644
--- a/packages/plugin-list/src/useResolvedAuthoredFilter.ts
+++ b/packages/react/src/hooks/useResolvedFilter.ts
@@ -7,19 +7,22 @@
*/
/**
- * The hold that resolves a data node's OWN authored `filter` once, shared by
- * the two nodes in this package that query with it: `ListView`
- * (objectui#10607) and `ObjectGallery` (objectui#10666). It was local to
- * `ListView.tsx` until the gallery needed the same hold; it moved here
- * unchanged rather than being copied. Package-internal: `index.tsx` does not
- * re-export it, so the package's export set does not move.
+ * The ONE hold every data node uses to resolve its OWN authored `filter`
+ * (objectui#10666). PR objectui#10656 wrote it for `list-view`
+ * (`useResolvedAuthoredFilter`, local to `ListView.tsx`); PR objectui#10705
+ * moved it into a module of `plugin-list` for `object-gallery`; the sweep that
+ * closed objectui#10666 moved it here, beside `useFilterScope`, so the nine
+ * packages whose nodes send their own filter share one copy instead of growing
+ * nine. The hold, its key and its comparator moved unchanged; the one addition
+ * is that a filter the resolver leaves unchanged is handed out as authored (see
+ * `resolveHeldFilter`).
*/
import * as React from 'react';
import { resolveFilterPlaceholders, type FilterTokenScope } from '@object-ui/core';
/** One resolution of the node's own `filter`, remembered with its inputs. */
-interface HeldAuthoredFilter {
+interface HeldFilter {
authored: unknown;
currentUserId: FilterTokenScope['currentUserId'];
currentOrgId: FilterTokenScope['currentOrgId'];
@@ -27,13 +30,21 @@ interface HeldAuthoredFilter {
resolved: unknown;
}
-function resolveAuthoredFilter(authored: unknown, scope: FilterTokenScope): HeldAuthoredFilter {
+function resolveHeldFilter(authored: unknown, scope: FilterTokenScope): HeldFilter {
+ const resolved = resolveFilterPlaceholders(authored, scope);
return {
authored,
currentUserId: scope.currentUserId,
currentOrgId: scope.currentOrgId,
onUnresolved: scope.onUnresolved,
- resolved: resolveFilterPlaceholders(authored, scope),
+ // `resolveFilterPlaceholders` copies every array and plain object it walks,
+ // so a filter with nothing to resolve comes back EQUAL but not the same
+ // reference. Such a filter is handed out as authored: a node whose filter
+ // carries no token sends the very value it was given, as it did before it
+ // resolved anything (the `object-kanban` / `object-calendar` / `object-tree`
+ // member pins read `$filter` by identity). Only a filter the resolver
+ // actually changed is handed out as the resolved copy.
+ resolved: isSameAuthoredFilter(resolved, authored) ? authored : resolved,
};
}
@@ -49,13 +60,12 @@ function isPlainFilterObject(value: unknown): value is Record {
/**
* Compare two authored filters by structure, never by serialising them.
*
- * The same rules as `plugin-view`'s `isStructurallyEqual` (objectui#6460),
- * which this package does not depend on: primitives by `Object.is`, a `Date`
- * by its instant, arrays element by element and in order, plain objects by key
- * set and value. Anything else is equal only as the same reference, and a
- * structure deeper than the bound is "changed". Every uncertainty answers
- * "changed", which re-resolves and re-queries; the comparison can drop a
- * redundant query, never a needed one.
+ * The same rules as `plugin-view`'s `isStructurallyEqual` (objectui#6460):
+ * primitives by `Object.is`, a `Date` by its instant, arrays element by
+ * element and in order, plain objects by key set and value. Anything else is
+ * equal only as the same reference, and a structure deeper than the bound is
+ * "changed". Every uncertainty answers "changed", which re-resolves and
+ * re-queries; the comparison can drop a redundant query, never a needed one.
*/
function isSameAuthoredFilter(a: unknown, b: unknown, depth = 0): boolean {
if (Object.is(a, b)) return true;
@@ -78,31 +88,41 @@ function isSameAuthoredFilter(a: unknown, b: unknown, depth = 0): boolean {
}
/**
- * objectui#10607 — resolve the node's own `filter` ONCE, through
+ * Resolve a data node's own authored `filter` ONCE, and hold the result.
+ *
+ * Every context token the spec defines for filters (`{current_user_id}`,
+ * `{current_org_id}`, and the date macros such as `{today}`) is expanded by
* `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the session
- * scope the host provides (`useFilterScope`). Before this, a directly authored
- * `list-view` with `filter: [['owner', '=', '{current_user_id}']]` sent the
- * literal token on `$filter`, and handed it to the child view it renders.
- * `object-view` already resolves the filters it hands a `list-view` host
- * (objectui#10506); resolving that value again changes nothing, because a
- * resolved id no longer matches the whole-token pattern.
+ * scope the host provides. Pass `useFilterScope()` as `scope`:
+ *
+ * ```tsx
+ * const filterScope = useFilterScope();
+ * const filter = useResolvedFilter(schema.filter, filterScope);
+ * // query with `filter`, and key the fetch effect on `filter`, never on schema.filter
+ * ```
*
* ⛔ Not a second resolver, and no fallback: a token the scope cannot resolve
* is whatever `resolveFilterPlaceholders` makes of it (left intact, with one
* warning naming it).
*
* The result is HELD against its inputs, the shape `plugin-view`'s
- * `useResolvedFilterSegments` uses. The fetch effect keys on this filter, so a
+ * `useResolvedFilterSegments` uses. A fetch effect keys on this filter, so a
* resolved copy minted on every render would refetch on every render; and a
* date macro such as `{now}` resolves to a new value at every call, so
* comparing OUTPUTS cannot stop that. The key is the authored filter, compared
* by structure (a host that rebuilds an equal filter inline must not
* re-query), plus the scope's three members read one by one, never the scope
* object's identity (AGENTS.md #10). The held pair lives in state, so the
- * value handed out is always the one React committed.
+ * value handed out is always the one React committed. A structurally different
+ * filter, or a changed scope member, resolves again and hands out a new value.
+ *
+ * A filter the resolver leaves unchanged (no token, or only tokens the scope
+ * cannot resolve) is handed out as the authored value itself, not as the
+ * resolver's equal copy, so a token-free filter reaches the query exactly as
+ * it did before any resolution.
*/
-export function useResolvedAuthoredFilter(authored: T, scope: FilterTokenScope): T {
- const [held, setHeld] = React.useState(() => resolveAuthoredFilter(authored, scope));
+export function useResolvedFilter(authored: T, scope: FilterTokenScope): T {
+ const [held, setHeld] = React.useState(() => resolveHeldFilter(authored, scope));
if (
held.currentUserId !== scope.currentUserId
|| held.currentOrgId !== scope.currentOrgId
@@ -112,7 +132,7 @@ export function useResolvedAuthoredFilter(authored: T, scope: FilterTokenScop
// React's documented "information from previous renders" shape: a set
// during render re-renders this component at once, before any child sees
// the discarded pass, and the re-render finds the inputs equal.
- const next = resolveAuthoredFilter(authored, scope);
+ const next = resolveHeldFilter(authored, scope);
setHeld(next);
return next.resolved as T;
}
diff --git a/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts b/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts
index 43925c25e1..e0bc0b8675 100644
--- a/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts
+++ b/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts
@@ -14,7 +14,8 @@
* declares it (`ComponentPropsMap['object-kanban']`,
* `ComponentPropsMap['object-calendar']`); both plugins' registration `inputs`
* publish it; both renderers READ it — `plugin-kanban/src/ObjectKanban.tsx`
- * lowers `schema.filter` onto `$filter` at its `dataSource.find` call, and
+ * lowers `schema.filter` onto `$filter` at its `dataSource.find` call (its
+ * context tokens resolved first, since objectui#10666), and
* `plugin-calendar/src/ObjectCalendar.tsx` does the same and additionally
* lowers `schema.sort` onto `$orderby` through `convertSortToQueryParams`. The
* declaration face of `@object-ui/types` named none of them: an authored value
@@ -206,7 +207,11 @@ describe('objectui#8174 — the renderers read these keys, which is what the dec
// The positive control: the query that returns a verdict for `sort` below
// is the same query that returns `objectName`, so that verdict is a reading.
expect(reads.has(READ_CONTROL_KEY)).toBe(true);
- expect(readRepo(KANBAN_READER)).toContain('$filter: schema.filter');
+ // objectui#10666: the read goes through the context-token hold first, and
+ // the held value is what reaches `$filter`.
+ const src = readRepo(KANBAN_READER);
+ expect(src).toContain('useResolvedFilter(schema.filter, filterScope)');
+ expect(src).toContain('$filter: queryFilter');
});
it('…and reads `sort` ONLY as the binding carrier: `dataSource.sort` lowered onto `$orderby` (objectui#10068)', () => {
@@ -229,7 +234,9 @@ describe('objectui#8174 — the renderers read these keys, which is what the dec
expect(reads.has('sort'), `${CALENDAR_READER} no longer reads schema.sort`).toBe(true);
expect(reads.has(READ_CONTROL_KEY)).toBe(true);
const src = readRepo(CALENDAR_READER);
- expect(src).toContain('$filter: schema.filter');
+ // objectui#10666: `filter` reaches `$filter` through the context-token hold.
+ expect(src).toContain('useResolvedFilter(schema.filter, filterScope)');
+ expect(src).toContain('$filter: queryFilter');
expect(src).toContain('$orderby: convertSortToQueryParams(schema.sort)');
});
diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts
index 3dfca9c073..06024d7291 100644
--- a/packages/types/src/objectql.ts
+++ b/packages/types/src/objectql.ts
@@ -25,7 +25,7 @@ import type { BaseSchema } from './base.js';
import type { KanbanCard } from './complex.js';
import type { DrillDownConfig } from './data-display.js';
// `QueryParams` is the destination `ObjectGallerySchema.filter`'s own docblock
-// names — the value is forwarded verbatim into that slot — so the declaration
+// names — the value is forwarded into that slot, context tokens resolved — so the declaration
// is an INDEXED ACCESS on it rather than a copy of its arms (objectui#9309).
// Type-only: no runtime edge, and `./data.ts` imports nothing from here, so
// this adds no cycle in either direction.
@@ -2852,7 +2852,10 @@ export interface ObjectMapSchema extends BaseSchema {
data?: ViewData;
/** Inline records, wrapped into a `{ provider: 'value' }` data config; read SECOND */
staticData?: any[];
- /** Query filter, forwarded verbatim as `$filter` */
+ /**
+ * Query filter, forwarded as `$filter` with its
+ * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666).
+ */
filter?: any[];
/** Sort configuration, forwarded as `$orderby`. Array only — the legacy string clause is retired (objectui#8221). */
sort?: SortConfig[];
@@ -2911,14 +2914,15 @@ export interface ObjectTreeSchema extends BaseSchema {
/** ObjectQL object name */
objectName: string;
/**
- * Query filter, forwarded verbatim as `$filter` on the tree's own fetch.
+ * Query filter, forwarded as `$filter` on the tree's own fetch, with its
+ * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666).
*
* Typed as that DESTINATION by an indexed access on {@link QueryParams}, the
* shape objectui#9309 settled for {@link ObjectGallerySchema.filter}, so the
* declaration cannot drift away from the slot it is forwarded into
* (objectui#9549). The key was already delivered and read before it was
* declared: `ListView` puts `filter` on the `baseProps` every child view
- * receives, and `ObjectTree` sends `$filter: schema.filter`. Until this
+ * receives, and `ObjectTree` sends it as `$filter`. Until this
* declaration the value survived only on `BaseSchema`'s index signature, so
* declaring it NARROWS: `filter: 'stage=won'` and `filter: 42` are compile
* errors now.
@@ -3259,7 +3263,10 @@ export interface ObjectGanttSchema extends BaseSchema {
// and `sort` as the two keys the element data-source binding maps onto.
/** Inline records, wrapped into a `{ provider: 'value' }` config by `resolveRecordSourceConfig`. */
staticData?: any[];
- /** Query filter (JSON Rules format), forwarded verbatim as `$filter`. */
+ /**
+ * Query filter (JSON Rules format), forwarded as `$filter` with its
+ * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666).
+ */
filter?: any[];
/** Sort configuration, forwarded as `$orderby` via `convertSortToQueryParams`. Array only — the legacy string clause is retired (objectui#8221). */
sort?: SortConfig[];
@@ -3472,10 +3479,12 @@ export interface ObjectCalendarSchema extends BaseSchema {
*/
defaultView?: 'month' | 'week' | 'day';
/**
- * Query filter (JSON Rules format), forwarded verbatim as `$filter` on the
- * calendar's own fetch — `plugin-calendar/src/ObjectCalendar.tsx` reads
- * `schema.filter` at the `dataSource.find` call and again in that effect's
- * dependency list.
+ * Query filter (JSON Rules format), forwarded as `$filter` on the
+ * calendar's own fetch — `plugin-calendar/src/ObjectCalendar.tsx` holds it
+ * through `useResolvedFilter` (`@object-ui/react`), with its
+ * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666),
+ * and reads the held value at the `dataSource.find` call and again in that
+ * effect's dependency list.
*
* Undeclared here until objectui#8174, so an authored value reached the
* renderer only through {@link BaseSchema}'s `[key: string]: any` — admitted,
@@ -3858,10 +3867,12 @@ export interface ObjectKanbanSchema extends BaseSchema {
*/
limit?: number;
/**
- * Query filter (JSON Rules format), forwarded verbatim as `$filter` on the
- * board's own fetch — `plugin-kanban/src/ObjectKanban.tsx` reads
- * `schema.filter` at the `dataSource.find` call, alongside the `$top` that
- * {@link limit} feeds, and again in that effect's dependency list.
+ * Query filter (JSON Rules format), forwarded as `$filter` on the board's
+ * own fetch — `plugin-kanban/src/ObjectKanban.tsx` holds it through
+ * `useResolvedFilter` (`@object-ui/react`), with its
+ * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666),
+ * and reads the held value at the `dataSource.find` call, alongside the
+ * `$top` that {@link limit} feeds, and again in that effect's dependency list.
*
* Undeclared here until objectui#8174, so an authored value reached the
* renderer only through {@link BaseSchema}'s `[key: string]: any` — admitted,
@@ -4282,8 +4293,10 @@ export interface ObjectChartSchema extends BaseSchema {
/** Dataset measure names */
values?: string[];
/**
- * AUTHORABLE — query filter, forwarded verbatim as `$filter` on both query
- * legs (`ds.aggregate` and `ds.find`), and conjoined with the click context
+ * AUTHORABLE — query filter, forwarded as `$filter` on both query legs
+ * (`ds.aggregate` and `ds.find`) with its context tokens (`{current_user_id}`,
+ * `{current_org_id}`, the date macros) resolved first through
+ * `@object-ui/core`'s `resolveFilterPlaceholders`, and conjoined with the click context
* to scope a drill-down.
*
* ⚠️ BOTH shapes, and the union is measured rather than tidied. The array arm
@@ -4294,7 +4307,7 @@ export interface ObjectChartSchema extends BaseSchema {
* {@link ObjectKanbanSchema.filter} carry. The RECORD arm is what the reads
* require: the in-repo corpus authors the ObjectQL object form
* (`{ close_date: { $gte, $lte } }`) against fakes that read it that way, and
- * both arms travel verbatim to `ds.aggregate` / `ds.find` as `$filter`.
+ * both arms travel to `ds.aggregate` / `ds.find` as `$filter`, resolved.
* Declaring only the array arm would have refused live, working charts.
*
* ⚠️ Narrowing to ONE arm is a decision LOCAL TO THIS NODE, not a
@@ -4579,7 +4592,8 @@ export interface ObjectGallerySchema extends BaseSchema {
/** ObjectQL object name; omitted when the records arrive through `bind` or `data` */
objectName?: string;
/**
- * Query filter, forwarded verbatim as `$filter`.
+ * Query filter, forwarded as `$filter` with its
+ * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666).
*
* Typed as the DESTINATION the sentence above names, by an indexed access on
* {@link QueryParams} rather than a copy of its arms, so the declaration
diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts
index 08a0fe0fe7..69a991aca8 100644
--- a/packages/types/src/zod/objectql.zod.ts
+++ b/packages/types/src/zod/objectql.zod.ts
@@ -1827,7 +1827,7 @@ export const ObjectTreeSchema = BaseSchema.extend({
filter: z.union([
z.array(z.any()),
z.record(z.string(), z.any()),
- ]).optional().describe('Query filter, forwarded verbatim as $filter. FilterArray (the spec array sugar) OR the ObjectQL $filter object — the two arms of QueryParams[$filter]'),
+ ]).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first. FilterArray (the spec array sugar) OR the ObjectQL $filter object — the two arms of QueryParams[$filter]'),
parentField: z.string().optional().describe('Single-parent pointer field (auto-detected when omitted)'),
labelField: z.string().optional().describe('Field rendered indented in the first column'),
fields: z.array(z.string()).optional().describe('Additional flat columns'),
@@ -1972,7 +1972,7 @@ export const ObjectGanttSchema = BaseSchema.extend({
// `ObjectGridSchema` — what `ObjectGanttProps.schema` used to be typed as before
// objectui#5903 retyped it to `ObjectGanttSchema` — so they need declaring here.
staticData: z.array(z.any()).optional().describe('Inline records, wrapped into a { provider: value } data config — read SECOND by resolveRecordSourceConfig'),
- filter: z.array(z.any()).optional().describe('Query filter, forwarded verbatim as $filter'),
+ filter: z.array(z.any()).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first'),
sort: z.array(SortConfigSchema).optional().describe('Sort configuration, forwarded as $orderby (array only; the legacy string clause is retired — objectui#8221)'),
// objectui#10250 — the full-text pair the record query carries, declared in
// step with the twin in `../objectql.ts`. ListView's toolbar Search writes
@@ -2090,7 +2090,7 @@ export const ObjectCalendarSchema = BaseSchema.extend({
// clause objectui#8221 retired, moves from "parses green here, then silently
// dropped by `convertSortToQueryParams` at runtime" to "refused at authoring
// time".
- filter: z.array(z.any()).optional().describe('Query filter, forwarded verbatim as $filter'),
+ filter: z.array(z.any()).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first'),
sort: z.array(SortConfigSchema).optional().describe('Sort configuration, forwarded as $orderby (array only; the legacy string clause is retired — objectui#8221)'),
}).superRefine(requireRecordSource('object-calendar'));
@@ -2298,7 +2298,7 @@ export const ObjectKanbanSchema = BaseSchema.extend({
// top-level `sort`. `ObjectKanban.tsx` reads `schema.sort` only as the
// `ElementDataSourceGate` carrier for the binding's `dataSource.sort`
// (objectui#10068). Only `ObjectCalendarSchema` above carries both.
- filter: z.array(z.any()).optional().describe('Query filter, forwarded verbatim as $filter'),
+ filter: z.array(z.any()).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first'),
// objectui#9606 — the CANONICAL card-title spelling, declared beside the
// legacy alias below exactly as `@objectstack/spec` declares the pair on
// `ObjectKanbanPropsSchema` (`cardTitle` first, `titleField` as its fallback).
@@ -2501,7 +2501,7 @@ export const ObjectChartSchema = BaseSchema.extend({
filter: z.union([
z.array(z.any()),
z.record(z.string(), z.any()),
- ]).optional().describe('AUTHORABLE — query filter, forwarded verbatim as $filter on both query legs, then spread into the drill-down filter. FilterArray (the spec/react-blocks and registry-inputs spelling) OR the ObjectQL $filter object'),
+ ]).optional().describe('AUTHORABLE — query filter, forwarded as $filter on both query legs with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first, then spread into the drill-down filter. FilterArray (the spec/react-blocks and registry-inputs spelling) OR the ObjectQL $filter object'),
// ⛔ `aggregate` is the SPEC's own schema, never a local near-copy. The first
// cut of objectui#7946 spelled it as a local `z.object` with all three members
// optional; zod 4 objects are STRIP-postured, so
@@ -2638,7 +2638,7 @@ export const ObjectGallerySchema = BaseSchema.extend({
filter: z.union([
z.array(z.any()),
z.record(z.string(), z.any()),
- ]).optional().describe('Query filter, forwarded verbatim as $filter. FilterArray (the spec array sugar) OR the ObjectQL $filter object — the two arms of QueryParams[$filter]'),
+ ]).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first. FilterArray (the spec array sugar) OR the ObjectQL $filter object — the two arms of QueryParams[$filter]'),
data: z.array(z.record(z.string(), z.unknown())).optional().describe('Inline records'),
gallery: stripImportedDefaults(SpecGalleryConfigSchema).optional().describe('Gallery configuration (@objectstack/spec GalleryConfig)'),
navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Record navigation behaviour (drawer/dialog/page)'),