From 83fe99604977006bfdb03e22e8907378e15fc25e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:26:38 +0000 Subject: [PATCH 1/5] fix(plugin-grid): resolve context tokens in a directly authored object-grid filter (objectui#10607) The node's own `filter`, and its deprecated alias `defaultFilters`, are resolved once through @object-ui/core's shared resolveFilterPlaceholders against useFilterScope(), and held against their inputs (compared by structure, the scope's members one by one) so an equal filter keeps its reference and does not refetch. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- packages/plugin-grid/src/ObjectGrid.tsx | 134 ++++++++++- ...ectGrid.filterContextTokens-10607.test.tsx | 215 ++++++++++++++++++ 2 files changed, 344 insertions(+), 5 deletions(-) create mode 100644 packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index c400e286c5..5ca7a760d0 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -25,7 +25,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { ObjectGridSchema, DataSource, ListColumn, TableColumn, ViewData, TableSortItem, DataTableSchema, ListViewExportFormat } from '@object-ui/types'; import { isSystemManagedField, normalizeTableColumnType } from '@object-ui/types'; import type { I18nLabel } from '@objectstack/spec/ui'; -import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useRelatedRecordActions, useDataInvalidation } from '@object-ui/react'; +import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useRelatedRecordActions, useDataInvalidation, useFilterScope } from '@object-ui/react'; import { createSafeTranslation } from '@object-ui/i18n'; // objectui#8920 — the grid reaches a cell renderer through THIS module and // nowhere else. `getCellRenderer` / `resolveCellRendererType` are deliberately @@ -54,7 +54,7 @@ import { RefreshIndicator, } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, isObjectInlineEditable, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNode, toFilterNodeSafely, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceConfig, resolveRecordSourceObjectName } from '@object-ui/core'; +import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, isObjectInlineEditable, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNode, toFilterNodeSafely, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceConfig, resolveRecordSourceObjectName, resolveFilterPlaceholders, type FilterTokenScope } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { RECORD_OVERLAY_DEFAULT_WIDTH, @@ -78,6 +78,116 @@ import { BulkActionDialog } from './components/BulkActionDialog'; import type { BulkResult } from './hooks/useBulkExecutor'; import type { BulkActionDef } from '@object-ui/types'; +/** + * The node's own authored filter: the canonical `filter`, and its deprecated + * alias `defaultFilters`, which the load effect reads only when `filter` is + * absent. + */ +interface AuthoredGridFilters { + filter: ObjectGridSchema['filter']; + defaultFilters: ObjectGridSchema['defaultFilters']; +} + +/** One resolution, remembered with the inputs it was computed from. */ +interface HeldGridFilters { + authored: AuthoredGridFilters; + currentUserId: FilterTokenScope['currentUserId']; + currentOrgId: FilterTokenScope['currentOrgId']; + onUnresolved: FilterTokenScope['onUnresolved']; + resolved: AuthoredGridFilters; +} + +function resolveGridFilters(authored: AuthoredGridFilters, scope: FilterTokenScope): HeldGridFilters { + return { + authored, + currentUserId: scope.currentUserId, + currentOrgId: scope.currentOrgId, + onUnresolved: scope.onUnresolved, + resolved: resolveFilterPlaceholders(authored, scope), + }; +} + +/** Depth past which {@link isSameAuthoredFilter} gives up and answers "changed". */ +const AUTHORED_FILTER_MAX_DEPTH = 12; + +function isPlainFilterObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Compare two authored filters by structure, never by serialising them. + * + * The same rules as `plugin-view`'s `isStructurallyEqual` (objectui#6460), + * which this package cannot import: `plugin-view` depends on `plugin-grid`. + * 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; + if (depth >= AUTHORED_FILTER_MAX_DEPTH) return false; + if (a instanceof Date || b instanceof Date) { + return a instanceof Date && b instanceof Date && a.getTime() === b.getTime(); + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((item, i) => isSameAuthoredFilter(item, b[i], depth + 1)); + } + if (isPlainFilterObject(a) && isPlainFilterObject(b)) { + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + return keys.every((key) => ( + Object.prototype.hasOwnProperty.call(b, key) && isSameAuthoredFilter(a[key], b[key], depth + 1) + )); + } + return false; +} + +/** + * objectui#10607 — resolve the node's own filter ONCE, through + * `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the session + * scope the host provides (`useFilterScope`). Before this, a directly authored + * `object-grid` with `filter: [['owner', '=', '{current_user_id}']]` sent the + * literal token on `$filter`. `object-view` already resolves the filters it + * hands this grid (objectui#10506); resolving that value again changes + * nothing, because a resolved id no longer matches the whole-token pattern. + * + * ⛔ 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 load effect keys on the filter this + * grid lowers, 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 + * filters, 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. + */ +function useResolvedGridFilters(authored: AuthoredGridFilters, scope: FilterTokenScope): AuthoredGridFilters { + const [held, setHeld] = useState(() => resolveGridFilters(authored, scope)); + if ( + held.currentUserId !== scope.currentUserId + || held.currentOrgId !== scope.currentOrgId + || held.onUnresolved !== scope.onUnresolved + || !isSameAuthoredFilter(held.authored, authored) + ) { + // 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 = resolveGridFilters(authored, scope); + setHeld(next); + return next.resolved; + } + return held.resolved; +} + /** * A declared `sort` → the `"field order"` join string THIS block sends as * `$orderby` (objectui#8973). @@ -1758,7 +1868,17 @@ export const ObjectGrid: React.FC = ({ // it. The refusal is kept as a VALUE and rendered by the malformed-filter // branch below; collapsing it to `undefined` would mean "no filter" and run // the grid unconstrained, the silent widening objectui#9001 closed. - const schemaFilterSource = schema.filter; + // + // objectui#10607: both authored entries are read RESOLVED and held (see + // `useResolvedGridFilters`), so `{current_user_id}` goes out as the real id + // and an equal filter keeps its reference across renders. + const filterScope = useFilterScope(); + const authoredFilters = useResolvedGridFilters( + { filter: schema.filter, defaultFilters: schema.defaultFilters }, + filterScope, + ); + const schemaFilterSource = authoredFilters.filter; + const schemaDefaultFilters = authoredFilters.defaultFilters; const schemaFilterResult = useMemo(() => toFilterNodeSafely(schemaFilterSource), [schemaFilterSource]); const schemaFilterRefusal = schemaFilterResult.ok ? undefined : schemaFilterResult.refusal; const schemaFilter = schemaFilterResult.ok ? schemaFilterResult.node : undefined; @@ -2164,7 +2284,7 @@ export const ObjectGrid: React.FC = ({ // for a `FilterOperatorError` arriving on this path too, which is // the whole of step 2 for this fourth call site. Converting it to // `toFilterNodeSafely` here would only rethrow into the same catch. - const legacyFilter = toFilterNode(schema.defaultFilters); + const legacyFilter = toFilterNode(schemaDefaultFilters); if (legacyFilter !== undefined) { params.$filter = legacyFilter; } @@ -2357,7 +2477,11 @@ export const ObjectGrid: React.FC = ({ // reachable a second way. // `invalidationNonce` (objectui#10035): a write to this object, reported on // the data-invalidation bus — see its declaration above. - }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, serverPageSize, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey, invalidationNonce]); + // `schemaDefaultFilters` (objectui#10607): the deprecated alias, resolved and + // held with `filter`. It changes only when the authored alias changes by + // structure or the scope changes (a new signed-in user), and either one + // changes the `$filter` this effect sends when `filter` is absent. + }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaDefaultFilters, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, serverPageSize, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey, invalidationNonce]); // The same reset, for the path the loader above never runs on (objectui#4501 // clause 2). "All N matching are selected" is a claim about ONE query, so it diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx new file mode 100644 index 0000000000..57bc086d7d --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx @@ -0,0 +1,215 @@ +/** + * 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#10607 — a directly authored `object-grid` node resolves the spec's + * context tokens (`{current_user_id}`, `{current_org_id}`) in its OWN filter, + * through `@object-ui/core`'s ONE shared `resolveFilterPlaceholders`, against + * the session scope the host provides (`FilterScopeProvider` / + * `useFilterScope`). + * + * Before this change `plugin-grid` had zero reads of that resolver, so + * `filter: [['owner', '=', '{current_user_id}']]` on an `object-grid` authored + * straight into a page — not reached through `object-view`, which resolves the + * filters it hands on (objectui#10506) — went out on `$filter` as the literal + * token. + * + * The node's own filter is `filter` and its deprecated alias `defaultFilters` + * (read only when `filter` is absent); both are resolved, in one call. + * + * The grid keys its fetch on the filter it lowers, so the resolved value must + * keep its reference while nothing it was computed from has changed: a copy + * minted on every render would refetch on every render. The stability cells + * below count `find()` calls across re-renders that change nothing. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, waitFor, act, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, FilterScopeProvider } from '@object-ui/react'; +import { resolveFilterPlaceholders } from '@object-ui/core'; +// Registers `object-grid`. +import '../index'; + +const USER = 'usr_42'; +const ORG = 'org_7'; + +const MINE = [['owner', '=', '{current_user_id}']]; + +function makeAdapter() { + return { + find: vi.fn().mockResolvedValue({ data: [{ id: '1', name: 'Acme', owner: USER }], total: 1 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'task', + fields: { id: { type: 'text' }, name: { type: 'text' }, owner: { type: 'text' }, org: { type: 'text' } }, + }), + }; +} + +type Adapter = ReturnType; + +/** Held constant, so a re-render that rebuilds the node rebuilds only its filter. */ +const COLUMNS = ['name']; + +/** A directly authored `object-grid` node — no `object-view` above it. */ +function gridNode(over: Record) { + return { type: 'object-grid', objectName: 'task', columns: COLUMNS, ...over }; +} + +function ui(adapter: Adapter, node: Record, user: string | null = USER, org: string | null = ORG) { + return ( + + + + + + ); +} + +/** The `$filter` of the Nth `find()` the grid issued. */ +async function queriedFilter(find: Adapter['find'], call = 0) { + await waitFor(() => expect(find.mock.calls.length).toBeGreaterThan(call)); + return (find.mock.calls[call] as [string, any])[1]?.$filter; +} + +/** Let every pending effect and resolved promise land. */ +async function settle() { + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); +} + +let warn: ReturnType; + +beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + warn.mockRestore(); +}); + +describe('object-grid — the node’s own filter reaches the query resolved (objectui#10607)', () => { + it('sends the signed-in user id for {current_user_id}, not the literal token', async () => { + const adapter = makeAdapter(); + render(ui(adapter, gridNode({ filter: MINE }))); + expect(await queriedFilter(adapter.find)).toEqual([['owner', '=', USER]]); + }); + + it('sends the active organization id for {current_org_id}', async () => { + const adapter = makeAdapter(); + render(ui(adapter, gridNode({ filter: [['org', '=', '{current_org_id}']] }))); + expect(await queriedFilter(adapter.find)).toEqual([['org', '=', ORG]]); + }); + + it('resolves the deprecated `defaultFilters` alias too, when `filter` is absent', async () => { + const adapter = makeAdapter(); + render(ui(adapter, gridNode({ defaultFilters: { owner: '{current_user_id}' } }))); + const sent = JSON.stringify(await queriedFilter(adapter.find)); + expect(sent).toContain(USER); + expect(sent).not.toContain('{current_user_id}'); + }); + + it('CONTROL: a token-free filter reaches the query unchanged', async () => { + const adapter = makeAdapter(); + render(ui(adapter, gridNode({ filter: [['owner', '=', 'usr_literal']] }))); + expect(await queriedFilter(adapter.find)).toEqual([['owner', '=', 'usr_literal']]); + }); + + it('keeps the token literal with no user in scope, and the shared resolver names it (no fallback)', async () => { + const adapter = makeAdapter(); + render(ui(adapter, gridNode({ filter: MINE }), null, null)); + expect(await queriedFilter(adapter.find)).toEqual([['owner', '=', '{current_user_id}']]); + expect(warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('{current_user_id}'))).toBe(true); + }); + + it('re-queries with the new id when the signed-in user changes', async () => { + const adapter = makeAdapter(); + const node = gridNode({ filter: MINE }); + const { rerender } = render(ui(adapter, node)); + expect(await queriedFilter(adapter.find, 0)).toEqual([['owner', '=', USER]]); + await settle(); + const before = adapter.find.mock.calls.length; + await act(async () => { + rerender(ui(adapter, node, 'usr_99')); + }); + expect(await queriedFilter(adapter.find, before)).toEqual([['owner', '=', 'usr_99']]); + }); + + it('re-queries with the new id when the user changes and only `defaultFilters` carries the token', async () => { + const adapter = makeAdapter(); + const node = gridNode({ defaultFilters: { owner: '{current_user_id}' } }); + const { rerender } = render(ui(adapter, node)); + expect(JSON.stringify(await queriedFilter(adapter.find, 0))).toContain(USER); + await settle(); + const before = adapter.find.mock.calls.length; + await act(async () => { + rerender(ui(adapter, node, 'usr_99')); + }); + expect(JSON.stringify(await queriedFilter(adapter.find, before))).toContain('usr_99'); + }); +}); + +describe('object-grid — the resolved filter is held, so an equal filter does not refetch (objectui#10607)', () => { + it('issues no further query across re-renders with the SAME node', async () => { + const adapter = makeAdapter(); + const node = gridNode({ filter: MINE }); + const { rerender } = render(ui(adapter, node)); + await queriedFilter(adapter.find); + await settle(); + const settled = adapter.find.mock.calls.length; + for (let i = 0; i < 3; i++) { + await act(async () => { + rerender(ui(adapter, node)); + }); + await settle(); + } + expect(adapter.find.mock.calls.length).toBe(settled); + }); + + it('issues no further query across re-renders that rebuild an EQUAL token filter', async () => { + // A host that writes the node inline hands a fresh, structurally equal + // filter on every render; the hold compares by structure. + const adapter = makeAdapter(); + const fresh = () => gridNode({ filter: MINE.map((rule) => [...rule]) }); + const { rerender } = render(ui(adapter, fresh())); + expect(await queriedFilter(adapter.find)).toEqual([['owner', '=', USER]]); + await settle(); + const settled = adapter.find.mock.calls.length; + for (let i = 0; i < 3; i++) { + await act(async () => { + rerender(ui(adapter, fresh())); + }); + await settle(); + } + expect(adapter.find.mock.calls.length).toBe(settled); + }); + + it('the object-view seam: a filter already resolved upstream is queried once, unchanged (resolving twice is a no-op)', async () => { + // `object-view` hands `object-grid` a filter it has already resolved + // through the same resolver (objectui#10506). This grid resolves it again: + // a resolved id no longer matches the whole-token pattern, so the second + // pass changes nothing, and nothing about it re-queries. + const adapter = makeAdapter(); + const handed = resolveFilterPlaceholders(MINE, { currentUserId: USER, currentOrgId: ORG }); + const node = gridNode({ filter: handed }); + const { rerender } = render(ui(adapter, node, 'someone_else')); + expect(await queriedFilter(adapter.find)).toEqual(handed); + await settle(); + await act(async () => { + rerender(ui(adapter, node, 'someone_else')); + }); + await settle(); + expect(adapter.find).toHaveBeenCalledTimes(1); + }); +}); From 6aac6acbb2b62c417ed59bf935c24de1049f2a5a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:28:48 +0000 Subject: [PATCH 2/5] fix(plugin-list): resolve context tokens in a directly authored list-view filter (objectui#10607) The node's own `filter` is resolved once through @object-ui/core's shared resolveFilterPlaceholders against useFilterScope(), and held against its inputs (compared by structure, the scope's members one by one). The fetch, the page-reset signature, the self-querying views, the child view's node, the export and the empty-state copy all read the held value. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- packages/plugin-list/src/ListView.tsx | 141 ++++++++++-- ...istView.filterContextTokens-10607.test.tsx | 215 ++++++++++++++++++ 2 files changed, 340 insertions(+), 16 deletions(-) create mode 100644 packages/plugin-list/src/__tests__/ListView.filterContextTokens-10607.test.tsx diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 451b85295e..b4b558485b 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -15,13 +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 } from '@object-ui/react'; +import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope, useFilterScope } 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 { 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, resolveFilterPlaceholders, type FilterTokenScope } from '@object-ui/core'; 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 @@ -691,6 +691,107 @@ export function buildEffectiveFilter( const WITHHELD_FILTER_GROUP: FilterGroup = { id: 'root', logic: 'and', conditions: [] }; const WITHHELD_USER_FILTER_CONDITIONS: unknown[] = []; +/** One resolution of the node's own `filter`, remembered with its inputs. */ +interface HeldAuthoredFilter { + authored: unknown; + currentUserId: FilterTokenScope['currentUserId']; + currentOrgId: FilterTokenScope['currentOrgId']; + onUnresolved: FilterTokenScope['onUnresolved']; + resolved: unknown; +} + +function resolveAuthoredFilter(authored: unknown, scope: FilterTokenScope): HeldAuthoredFilter { + return { + authored, + currentUserId: scope.currentUserId, + currentOrgId: scope.currentOrgId, + onUnresolved: scope.onUnresolved, + resolved: resolveFilterPlaceholders(authored, scope), + }; +} + +/** Depth past which {@link isSameAuthoredFilter} gives up and answers "changed". */ +const AUTHORED_FILTER_MAX_DEPTH = 12; + +function isPlainFilterObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * 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. + */ +function isSameAuthoredFilter(a: unknown, b: unknown, depth = 0): boolean { + if (Object.is(a, b)) return true; + if (depth >= AUTHORED_FILTER_MAX_DEPTH) return false; + if (a instanceof Date || b instanceof Date) { + return a instanceof Date && b instanceof Date && a.getTime() === b.getTime(); + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((item, i) => isSameAuthoredFilter(item, b[i], depth + 1)); + } + if (isPlainFilterObject(a) && isPlainFilterObject(b)) { + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + return keys.every((key) => ( + Object.prototype.hasOwnProperty.call(b, key) && isSameAuthoredFilter(a[key], b[key], depth + 1) + )); + } + return false; +} + +/** + * objectui#10607 — resolve the node's own `filter` ONCE, through + * `@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. + * + * ⛔ 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 + * 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. + */ +function useResolvedAuthoredFilter(authored: T, scope: FilterTokenScope): T { + const [held, setHeld] = React.useState(() => resolveAuthoredFilter(authored, scope)); + if ( + held.currentUserId !== scope.currentUserId + || held.currentOrgId !== scope.currentOrgId + || held.onUnresolved !== scope.onUnresolved + || !isSameAuthoredFilter(held.authored, authored) + ) { + // 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); + setHeld(next); + return next.resolved as T; + } + return held.resolved as T; +} + export function convertFilterGroupToAST(group: FilterGroup): any[] { if (!group || !group.conditions || group.conditions.length === 0) return []; @@ -1104,6 +1205,14 @@ export const ListView = React.forwardRef(({ // already-canonical path (the common case). const schema = React.useMemo(() => normalizeListViewSchema(propSchema), [propSchema]); + // 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 + // 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); + // Convenience: resolve field label with schema.objectName pre-bound const tFieldLabel = React.useCallback( (fieldName: string, fallback: string) => @@ -2187,7 +2296,7 @@ export const ListView = React.forwardRef(({ try { // Construct filter — shared with the export path so the file a user // downloads is built from the same three sources as the rows on screen. - const finalFilter = buildEffectiveFilter(schema.filter, appliedFilters, appliedUserFilterConditions); + const finalFilter = buildEffectiveFilter(authoredFilter, appliedFilters, appliedUserFilterConditions); // Convert sort to query format // Use array format to ensure order is preserved (Object keys are not guaranteed ordered) @@ -2696,7 +2805,7 @@ export const ListView = React.forwardRef(({ // silently un-suppresses nothing, because the finding it was suppressing // simply moves elsewhere. Add prose ABOVE this point, never below it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schema.objectName, schema.data, dataSource, schema.filter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, ganttOwnsData]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change + }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, ganttOwnsData]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change // Any change to the result-defining inputs (object, filters, sort, search, // grouping, page size) invalidates the current page number — snap back to @@ -2706,7 +2815,7 @@ export const ListView = React.forwardRef(({ // from under a user who just turned it. serverPage is deliberately NOT part of // the signature, so turning the page never triggers a reset. const pageResetSignature = JSON.stringify([ - schema.objectName, schema.filter, effectivePageSize, currentSort, + schema.objectName, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, searchTerm, currentView, groupingConfig, ]); const prevPageResetSignature = React.useRef(pageResetSignature); @@ -3072,10 +3181,10 @@ export const ListView = React.forwardRef(({ * load-error panel that replaces the view. */ const selfQueryFilterRef = React.useRef<{ key: string; value: unknown } | null>(null); - let selfQueryFilter: unknown = schema.filter; + let selfQueryFilter: unknown = authoredFilter; if (currentView === 'gantt' || currentView === 'tree' || currentView === 'chart') { try { - const value = buildEffectiveFilter(schema.filter, appliedFilters, appliedUserFilterConditions); + const value = buildEffectiveFilter(authoredFilter, appliedFilters, appliedUserFilterConditions); const key = JSON.stringify(value ?? null); const cached = selfQueryFilterRef.current; if (cached && cached.key === key) { @@ -3086,7 +3195,7 @@ export const ListView = React.forwardRef(({ } } catch (error) { if (!(error instanceof FilterOperatorError)) throw error; - selfQueryFilter = selfQueryFilterRef.current ? selfQueryFilterRef.current.value : schema.filter; + selfQueryFilter = selfQueryFilterRef.current ? selfQueryFilterRef.current.value : authoredFilter; } } @@ -3125,7 +3234,7 @@ export const ListView = React.forwardRef(({ // surface speaking `filters`, so a child that fetches its own rows (the // chart branch below, and any of these rendered standalone) never saw the // view's base filter at all. - filter: schema.filter, + filter: authoredFilter, sort: currentSort, className: "h-full w-full", // Disable internal controls that clash with ListView toolbar @@ -3649,7 +3758,7 @@ export const ListView = React.forwardRef(({ // asynchronously (`/me/permissions`) and `objectDef` loads into state, so a // grid schema built before either resolved must be rebuilt when they do — // otherwise `editable` keeps the pre-verdict answer for the session. - }, [currentView, schema, currentSort, effectiveFields, hasAuthoredColumns, groupingConfig, rowColorConfig, navigation.handleClick, density.mode, galleryCardSize, inlineEdit, inlineEditOffered, objectDef, selfQueryFilter, ganttSearchTerm]); + }, [currentView, schema, authoredFilter, currentSort, effectiveFields, hasAuthoredColumns, groupingConfig, rowColorConfig, navigation.handleClick, density.mode, galleryCardSize, inlineEdit, inlineEditOffered, objectDef, selfQueryFilter, ganttSearchTerm]); const hasFilters = currentFilters.conditions && currentFilters.conditions.length > 0; @@ -4008,7 +4117,7 @@ export const ListView = React.forwardRef(({ .filter(Boolean) as string[]; // The same three filter sources as the data fetch, from the same function. - const finalFilter = buildEffectiveFilter(schema.filter, appliedFilters, appliedUserFilterConditions); + const finalFilter = buildEffectiveFilter(authoredFilter, appliedFilters, appliedUserFilterConditions); const sort = currentSort.length > 0 ? currentSort @@ -4110,7 +4219,7 @@ export const ListView = React.forwardRef(({ setShowExport(false); // `searchTerm` / `searchableFields` belong here: the export now narrows by // the active search, so a stale closure would export the wrong row set. - }, [data, effectiveFields, resolvedExportOptions, schema.objectName, schema.filter, schema.searchableFields, exportPermitted, dataSource, appliedFilters, appliedUserFilterConditions, currentSort, searchTerm, objectDef, resolveObjectLabel]); + }, [data, effectiveFields, resolvedExportOptions, schema.objectName, authoredFilter, schema.searchableFields, exportPermitted, dataSource, appliedFilters, appliedUserFilterConditions, currentSort, searchTerm, objectDef, resolveObjectLabel]); // All available fields for hide/show (with i18n) const allFields = React.useMemo(() => { @@ -4969,10 +5078,10 @@ export const ListView = React.forwardRef(({ // and sends triage away from the view layer, which is exactly what // this issue reported. const hasBaseFilter = - Array.isArray(schema.filter) - ? schema.filter.length > 0 - : !!schema.filter && typeof schema.filter === 'object' - ? Object.keys(schema.filter).length > 0 + Array.isArray(authoredFilter) + ? authoredFilter.length > 0 + : !!authoredFilter && typeof authoredFilter === 'object' + ? Object.keys(authoredFilter).length > 0 : false; const hasActiveQuery = !!(searchTerm && searchTerm.trim()) || diff --git a/packages/plugin-list/src/__tests__/ListView.filterContextTokens-10607.test.tsx b/packages/plugin-list/src/__tests__/ListView.filterContextTokens-10607.test.tsx new file mode 100644 index 0000000000..ec40bedd5b --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.filterContextTokens-10607.test.tsx @@ -0,0 +1,215 @@ +/** + * 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#10607 — a directly authored `list-view` node resolves the spec's + * context tokens (`{current_user_id}`, `{current_org_id}`) in its OWN filter, + * through `@object-ui/core`'s ONE shared `resolveFilterPlaceholders`, against + * the session scope the host provides (`FilterScopeProvider` / + * `useFilterScope`). + * + * Before this change `plugin-list` had zero reads of that resolver, so + * `filter: [['owner', '=', '{current_user_id}']]` on a `list-view` authored + * straight into a page went out on `$filter` as the literal token, and the + * child view it renders was handed the literal too. + * + * The list keys its fetch on the filter, so the resolved value must keep its + * reference while nothing it was computed from has changed: a copy minted on + * every render would refetch on every render. The stability cells below count + * `find()` calls across re-renders that change nothing. + */ + +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, waitFor, act, cleanup } from '@testing-library/react'; +import { ComponentRegistry, resolveFilterPlaceholders } from '@object-ui/core'; +import { SchemaRendererProvider, FilterScopeProvider } from '@object-ui/react'; +import type { ListViewSchema } from '@object-ui/types'; +import { ListView } from '../ListView'; + +const USER = 'usr_42'; +const ORG = 'org_7'; + +const MINE = [['owner', '=', '{current_user_id}']]; + +/** Held constant, so a re-render that rebuilds the node rebuilds only its filter. */ +const COLUMNS = ['name']; + +let gridSchemas: any[] = []; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [{ id: '1', name: 'Acme', owner: USER }], total: 1 })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => ({ + name, + fields: { id: { type: 'text' }, name: { type: 'text' }, owner: { type: 'text' }, org: { type: 'text' } }, + })), + }; +} + +type DS = ReturnType; + +/** A directly authored `list-view` node — no `object-view` above it. */ +function listNode(over: Record): ListViewSchema { + return { type: 'list-view', objectName: 'task', columns: COLUMNS, ...over } as unknown as ListViewSchema; +} + +function ui(ds: DS, schema: ListViewSchema, user: string | null = USER, org: string | null = ORG) { + return ( + + + + + + ); +} + +/** The `$filter` of the Nth `find()` the list issued. */ +async function queriedFilter(find: DS['find'], call = 0) { + await waitFor(() => expect(find.mock.calls.length).toBeGreaterThan(call)); + return (find.mock.calls[call] as unknown as [string, any])[1]?.$filter; +} + +/** Let every pending effect and resolved promise land. */ +async function settle() { + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); +} + +let prevObjectGrid: any; +beforeAll(() => { + prevObjectGrid = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', (props: any) => { + gridSchemas.push(props.schema); + return
; + }); +}); +afterAll(() => { + if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); + else ComponentRegistry.unregister('object-grid'); +}); + +let warn: ReturnType; + +beforeEach(() => { + gridSchemas = []; + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + warn.mockRestore(); +}); + +describe('list-view — the node’s own filter reaches the query resolved (objectui#10607)', () => { + it('sends the signed-in user id for {current_user_id}, not the literal token', async () => { + const ds = makeDataSource(); + render(ui(ds, listNode({ filter: MINE }))); + expect(await queriedFilter(ds.find)).toEqual([['owner', '=', USER]]); + }); + + it('sends the active organization id for {current_org_id}', async () => { + const ds = makeDataSource(); + render(ui(ds, listNode({ filter: [['org', '=', '{current_org_id}']] }))); + expect(await queriedFilter(ds.find)).toEqual([['org', '=', ORG]]); + }); + + it('hands the child view the resolved filter too', async () => { + const ds = makeDataSource(); + render(ui(ds, listNode({ filter: MINE }))); + await waitFor(() => expect(gridSchemas.length).toBeGreaterThan(0)); + expect(gridSchemas.at(-1).filter).toEqual([['owner', '=', USER]]); + }); + + it('CONTROL: a token-free filter reaches the query unchanged', async () => { + const ds = makeDataSource(); + render(ui(ds, listNode({ filter: [['owner', '=', 'usr_literal']] }))); + expect(await queriedFilter(ds.find)).toEqual([['owner', '=', 'usr_literal']]); + }); + + it('keeps the token literal with no user in scope, and the shared resolver names it (no fallback)', async () => { + const ds = makeDataSource(); + render(ui(ds, listNode({ filter: MINE }), null, null)); + expect(await queriedFilter(ds.find)).toEqual([['owner', '=', '{current_user_id}']]); + expect(warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('{current_user_id}'))).toBe(true); + }); + + it('re-queries with the new id when the signed-in user changes', async () => { + const ds = makeDataSource(); + const schema = listNode({ filter: MINE }); + const { rerender } = render(ui(ds, schema)); + expect(await queriedFilter(ds.find, 0)).toEqual([['owner', '=', USER]]); + await settle(); + const before = ds.find.mock.calls.length; + await act(async () => { + rerender(ui(ds, schema, 'usr_99')); + }); + expect(await queriedFilter(ds.find, before)).toEqual([['owner', '=', 'usr_99']]); + }); +}); + +describe('list-view — the resolved filter is held, so an equal filter does not refetch (objectui#10607)', () => { + it('issues no further query across re-renders with the SAME node', async () => { + const ds = makeDataSource(); + const schema = listNode({ filter: MINE }); + const { rerender } = render(ui(ds, schema)); + await queriedFilter(ds.find); + await settle(); + const settled = ds.find.mock.calls.length; + for (let i = 0; i < 3; i++) { + await act(async () => { + rerender(ui(ds, schema)); + }); + await settle(); + } + expect(ds.find.mock.calls.length).toBe(settled); + }); + + it('issues no further query across re-renders that rebuild an EQUAL token filter', async () => { + // A host that writes the node inline hands a fresh, structurally equal + // filter on every render; the hold compares by structure. + const ds = makeDataSource(); + const fresh = () => listNode({ filter: MINE.map((rule) => [...rule]) }); + const { rerender } = render(ui(ds, fresh())); + expect(await queriedFilter(ds.find)).toEqual([['owner', '=', USER]]); + await settle(); + const settled = ds.find.mock.calls.length; + for (let i = 0; i < 3; i++) { + await act(async () => { + rerender(ui(ds, fresh())); + }); + await settle(); + } + expect(ds.find.mock.calls.length).toBe(settled); + }); + + it('the object-view seam: a filter already resolved upstream is queried unchanged, and re-rendering does not re-query', async () => { + // `object-view` hands a `list-view` host a filter it has already resolved + // through the same resolver (objectui#10506). This list resolves it again: + // a resolved id no longer matches the whole-token pattern, so the second + // pass changes nothing, and nothing about it re-queries. + const ds = makeDataSource(); + const handed = resolveFilterPlaceholders(MINE, { currentUserId: USER, currentOrgId: ORG }); + const schema = listNode({ filter: handed }); + const { rerender } = render(ui(ds, schema, 'someone_else')); + expect(await queriedFilter(ds.find)).toEqual(handed); + await settle(); + const settled = ds.find.mock.calls.length; + await act(async () => { + rerender(ui(ds, schema, 'someone_else')); + }); + await settle(); + expect(ds.find.mock.calls.length).toBe(settled); + expect(ds.find.mock.calls.every((c) => JSON.stringify((c as unknown as [string, any])[1]?.$filter) === JSON.stringify(handed))).toBe(true); + }); +}); From abbd3f1f719a983331fe0ecc02bd1ed6034292ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:57:42 +0000 Subject: [PATCH 3/5] test(plugin-grid,plugin-list): type the objectui#10607 pins' casts Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- ...jectGrid.filterContextTokens-10607.test.tsx | 7 ++++--- ...ListView.filterContextTokens-10607.test.tsx | 18 +++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx index 57bc086d7d..0f86b2f8b4 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx @@ -33,6 +33,7 @@ import React from 'react'; import { render, waitFor, act, cleanup } from '@testing-library/react'; import { SchemaRenderer, SchemaRendererProvider, FilterScopeProvider } from '@object-ui/react'; import { resolveFilterPlaceholders } from '@object-ui/core'; +import type { BaseSchema, DataSource } from '@object-ui/types'; // Registers `object-grid`. import '../index'; @@ -68,8 +69,8 @@ function gridNode(over: Record) { function ui(adapter: Adapter, node: Record, user: string | null = USER, org: string | null = ORG) { return ( - - + + ); @@ -78,7 +79,7 @@ function ui(adapter: Adapter, node: Record, user: string | null /** The `$filter` of the Nth `find()` the grid issued. */ async function queriedFilter(find: Adapter['find'], call = 0) { await waitFor(() => expect(find.mock.calls.length).toBeGreaterThan(call)); - return (find.mock.calls[call] as [string, any])[1]?.$filter; + return (find.mock.calls[call] as unknown as [string, { $filter?: unknown }])[1]?.$filter; } /** Let every pending effect and resolved promise land. */ diff --git a/packages/plugin-list/src/__tests__/ListView.filterContextTokens-10607.test.tsx b/packages/plugin-list/src/__tests__/ListView.filterContextTokens-10607.test.tsx index ec40bedd5b..be1519b90b 100644 --- a/packages/plugin-list/src/__tests__/ListView.filterContextTokens-10607.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.filterContextTokens-10607.test.tsx @@ -29,7 +29,7 @@ import React from 'react'; import { render, waitFor, act, cleanup } from '@testing-library/react'; import { ComponentRegistry, resolveFilterPlaceholders } from '@object-ui/core'; import { SchemaRendererProvider, FilterScopeProvider } from '@object-ui/react'; -import type { ListViewSchema } from '@object-ui/types'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; import { ListView } from '../ListView'; const USER = 'usr_42'; @@ -40,7 +40,7 @@ const MINE = [['owner', '=', '{current_user_id}']]; /** Held constant, so a re-render that rebuilds the node rebuilds only its filter. */ const COLUMNS = ['name']; -let gridSchemas: any[] = []; +let gridSchemas: Array<{ filter?: unknown }> = []; function makeDataSource() { return { @@ -66,8 +66,8 @@ function listNode(over: Record): ListViewSchema { function ui(ds: DS, schema: ListViewSchema, user: string | null = USER, org: string | null = ORG) { return ( - - + + ); @@ -76,7 +76,7 @@ function ui(ds: DS, schema: ListViewSchema, user: string | null = USER, org: str /** The `$filter` of the Nth `find()` the list issued. */ async function queriedFilter(find: DS['find'], call = 0) { await waitFor(() => expect(find.mock.calls.length).toBeGreaterThan(call)); - return (find.mock.calls[call] as unknown as [string, any])[1]?.$filter; + return (find.mock.calls[call] as unknown as [string, { $filter?: unknown }])[1]?.$filter; } /** Let every pending effect and resolved promise land. */ @@ -86,10 +86,10 @@ async function settle() { }); } -let prevObjectGrid: any; +let prevObjectGrid: ReturnType; beforeAll(() => { prevObjectGrid = ComponentRegistry.get('object-grid'); - ComponentRegistry.register('object-grid', (props: any) => { + ComponentRegistry.register('object-grid', (props: { schema: { filter?: unknown } }) => { gridSchemas.push(props.schema); return
; }); @@ -128,7 +128,7 @@ describe('list-view — the node’s own filter reaches the query resolved (obje const ds = makeDataSource(); render(ui(ds, listNode({ filter: MINE }))); await waitFor(() => expect(gridSchemas.length).toBeGreaterThan(0)); - expect(gridSchemas.at(-1).filter).toEqual([['owner', '=', USER]]); + expect(gridSchemas.at(-1)?.filter).toEqual([['owner', '=', USER]]); }); it('CONTROL: a token-free filter reaches the query unchanged', async () => { @@ -210,6 +210,6 @@ describe('list-view — the resolved filter is held, so an equal filter does not }); await settle(); expect(ds.find.mock.calls.length).toBe(settled); - expect(ds.find.mock.calls.every((c) => JSON.stringify((c as unknown as [string, any])[1]?.$filter) === JSON.stringify(handed))).toBe(true); + expect(ds.find.mock.calls.every((c) => JSON.stringify((c as unknown as [string, { $filter?: unknown }])[1]?.$filter) === JSON.stringify(handed))).toBe(true); }); }); From 7f90de0fc64e637aed0d040ee50ee5425fa50aa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:59:18 +0000 Subject: [PATCH 4/5] docs(changeset): plugin-grid and plugin-list patch for objectui#10607 Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .../10607-grid-list-filter-context-tokens.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/10607-grid-list-filter-context-tokens.md diff --git a/.changeset/10607-grid-list-filter-context-tokens.md b/.changeset/10607-grid-list-filter-context-tokens.md new file mode 100644 index 0000000000..fddcfd5d8e --- /dev/null +++ b/.changeset/10607-grid-list-filter-context-tokens.md @@ -0,0 +1,41 @@ +--- +'@object-ui/plugin-grid': patch +'@object-ui/plugin-list': patch +--- + +A directly authored `object-grid` or `list-view` node resolves the spec's context tokens in its own filter (objectui#10607). + +`filter: [['owner', '=', '{current_user_id}']]` on an `object-grid` or `list-view` node authored +straight into a page, not reached through `object-view`, went out on `$filter` as the literal +token: neither package called `@object-ui/core`'s `resolveFilterPlaceholders`. Each now resolves +the node's own filter once through that shared resolver, with the session scope the host provides +through `useFilterScope` (the console shell mounts `FilterScopeProvider` from the signed-in user +and the active organization). On `object-grid` that is `filter` and its deprecated alias +`defaultFilters`: the resolved `filter` feeds the grid's query and its server-side export, and the +resolved alias feeds the query when `filter` is absent. On +`list-view` it is `filter`, and the resolved value feeds the list's query, the child view it +renders, the self-querying views (gantt, tree, chart), the export and the empty-state copy. This +closes the gap objectui#10506's entry notes, where a directly authored `object-grid` or +`list-view` node did not call the resolver. + +The relative-date macros (`{today}`, `{current_quarter_start}`, …) resolve in the same call, in +the browser's local time, as on the other surfaces that call the resolver. Where they used to +reach a backend that resolves them itself, as the ObjectStack server does in the tenant's +configured `localization.timezone` (UTC by default), a browser whose local day differs from the +server's day can now get a different day. + +A token the scope cannot resolve is left as written and the resolver warns once, which is the +resolver's own rule, so the filter is never widened. + +The resolved value is held against its inputs: the authored filter, compared by structure, and the +scope's three members (`currentUserId`, `currentOrgId`, `onUnresolved`), each compared on its own. +A re-render that changes none of them does not refetch, including one that rebuilds an equal +filter inline; a new signed-in user refetches with the new id. Date macros are therefore resolved +once and held until the scope or the authored filter changes, and a refetch for another reason +reuses the held value. `object-view` already hands these nodes a filter it has resolved +(objectui#10506); resolving that value again changes nothing, because a resolved id or date no +longer matches the placeholder pattern. + +On `object-grid`, the load effect now also refetches when the resolved `defaultFilters` changes (a +new signed-in user, or an in-place edit of the alias). It read that alias without keying on it +before. From d325bb2023ff2ff35c628c151e781a7b3d636a8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:53:38 +0000 Subject: [PATCH 5/5] docs(changeset): the objectui#10506 entry no longer says a directly authored grid or list skips the resolver (objectui#10607) `.changeset/10506-named-view-context-tokens.md`: one sentence, prose only, front matter byte-identical. After objectui#10607 both nodes call the shared resolver, so "(A directly authored `object-grid` or `list-view` node still does not call it.)" becomes "(A directly authored `object-grid` or `list-view` node calls it too, since objectui#10607.)". `.changeset/10607-grid-list-filter-context-tokens.md`: the sentence that pointed at the gap the 10506 entry used to note now says what is true of both entries. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .changeset/10506-named-view-context-tokens.md | 2 +- .changeset/10607-grid-list-filter-context-tokens.md | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.changeset/10506-named-view-context-tokens.md b/.changeset/10506-named-view-context-tokens.md index 44e882cca7..c958070033 100644 --- a/.changeset/10506-named-view-context-tokens.md +++ b/.changeset/10506-named-view-context-tokens.md @@ -19,7 +19,7 @@ schema handed to `ObjectGrid`, and the `list-view` schema handed to a host's `re The relative-date macros (`{today}`, `{current_quarter_start}`, …) resolve in the same call, in the browser's local time, as they already did on the surfaces that call the resolver: the app-shell host, `ObjectChart` and the dashboard widgets. (A directly authored `object-grid` or -`list-view` node still does not call it.) Where they used to reach a backend that resolves them +`list-view` node calls it too, since objectui#10607.) Where they used to reach a backend that resolves them itself, as the ObjectStack server does in the tenant's configured `localization.timezone` (UTC by default), a browser whose local day differs from the server's day can now get a different day. diff --git a/.changeset/10607-grid-list-filter-context-tokens.md b/.changeset/10607-grid-list-filter-context-tokens.md index fddcfd5d8e..35cbccb0ba 100644 --- a/.changeset/10607-grid-list-filter-context-tokens.md +++ b/.changeset/10607-grid-list-filter-context-tokens.md @@ -14,9 +14,8 @@ and the active organization). On `object-grid` that is `filter` and its deprecat `defaultFilters`: the resolved `filter` feeds the grid's query and its server-side export, and the resolved alias feeds the query when `filter` is absent. On `list-view` it is `filter`, and the resolved value feeds the list's query, the child view it -renders, the self-querying views (gantt, tree, chart), the export and the empty-state copy. This -closes the gap objectui#10506's entry notes, where a directly authored `object-grid` or -`list-view` node did not call the resolver. +renders, the self-querying views (gantt, tree, chart), the export and the empty-state copy. +`object-view` has done this since objectui#10506; these two nodes, authored directly, had not. The relative-date macros (`{today}`, `{current_quarter_start}`, …) resolve in the same call, in the browser's local time, as on the other surfaces that call the resolver. Where they used to