From 8f38562d859e1835a429f00e1cf77d176c011c43 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:18:05 +0000 Subject: [PATCH 1/2] test(plugin-list): pin context-token resolution in a directly authored object-gallery filter (objectui#10666) RED against the unfixed ObjectGallery: the node sends `{current_user_id}` and `{current_org_id}` literally on `$filter`. The list-view cell in the same harness is the lit control and passes. Co-Authored-By: Claude --- ...Gallery.filterContextTokens-10666.test.tsx | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 packages/plugin-list/src/__tests__/ObjectGallery.filterContextTokens-10666.test.tsx diff --git a/packages/plugin-list/src/__tests__/ObjectGallery.filterContextTokens-10666.test.tsx b/packages/plugin-list/src/__tests__/ObjectGallery.filterContextTokens-10666.test.tsx new file mode 100644 index 0000000000..2a70c2875f --- /dev/null +++ b/packages/plugin-list/src/__tests__/ObjectGallery.filterContextTokens-10666.test.tsx @@ -0,0 +1,198 @@ +/** + * 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 — a directly authored `object-gallery` 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`). This is the objectui#10607 class on a third host: + * `object-grid` and `list-view` were closed by objectui#10607, and a gallery + * rendered as a `list-view` child already received a resolved filter, but a + * gallery authored straight into a page sent the literal token on `$filter`. + * + * Rendered through the real `SchemaRenderer` and this package's own + * registrations, over a data source that records every `find()`. The + * `list-view` cells are the lit control: the same filter, the same scope, the + * same harness, on a node that already resolves (objectui#10607). Its child + * view is stubbed, so the control reads only the list's own query. + * + * The gallery 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 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 } from '@object-ui/core'; +import { SchemaRenderer, SchemaRendererProvider, FilterScopeProvider } from '@object-ui/react'; +import type { DataSource } from '@object-ui/types'; +// Registers `list-view` and `object-gallery` (the package's own registrations). +import '../index'; + +const USER = 'usr_42'; +const ORG = 'org_7'; + +const MINE = [['owner', '=', '{current_user_id}']]; + +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 `object-gallery` node — no `list-view` or `object-view` above it. */ +function galleryNode(over: Record) { + return { type: 'object-gallery', objectName: 'task', gallery: { titleField: 'name' }, ...over }; +} + +/** The lit control: a directly authored `list-view` node over the same object. */ +function listNode(over: Record) { + return { type: 'list-view', objectName: 'task', columns: ['name'], ...over }; +} + +function ui(ds: DS, schema: Record, user: string | null = USER, org: string | null = ORG) { + return ( + + + + + + ); +} + +/** The `$filter` of the Nth `find()` the node 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, { $filter?: unknown }])[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: ReturnType; +beforeAll(() => { + prevObjectGrid = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', () =>
); +}); +afterAll(() => { + if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); + else ComponentRegistry.unregister('object-grid'); +}); + +let warn: ReturnType; + +beforeEach(() => { + warn = vi.spyOn(console, 'warn').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 () => '{}' }))); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + warn.mockRestore(); +}); + +describe('object-gallery — the node’s own filter reaches the query resolved (objectui#10666)', () => { + it('LIT CONTROL: a directly authored list-view sends the signed-in user id for {current_user_id}', async () => { + const ds = makeDataSource(); + render(ui(ds, listNode({ filter: MINE }))); + expect(await queriedFilter(ds.find)).toEqual([['owner', '=', USER]]); + }); + + it('sends the signed-in user id for {current_user_id}, not the literal token', async () => { + const ds = makeDataSource(); + render(ui(ds, galleryNode({ 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, galleryNode({ filter: [['org', '=', '{current_org_id}']] }))); + expect(await queriedFilter(ds.find)).toEqual([['org', '=', ORG]]); + }); + + it('CONTROL: a token-free filter reaches the query unchanged', async () => { + const ds = makeDataSource(); + render(ui(ds, galleryNode({ 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, galleryNode({ 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 = galleryNode({ 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('object-gallery — the resolved filter is held, so an equal filter does not refetch (objectui#10666)', () => { + it('issues exactly one query, and none further across re-renders with the SAME node', async () => { + const ds = makeDataSource(); + const schema = galleryNode({ filter: MINE }); + const { rerender } = render(ui(ds, schema)); + await queriedFilter(ds.find); + await settle(); + expect(ds.find).toHaveBeenCalledTimes(1); + for (let i = 0; i < 3; i++) { + await act(async () => { + rerender(ui(ds, schema)); + }); + await settle(); + } + expect(ds.find).toHaveBeenCalledTimes(1); + }); + + 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 = () => galleryNode({ 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); + }); +}); From 59d06fcbf9b775c29ac8cac5e8881d5808474033 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:19:27 +0000 Subject: [PATCH 2/2] fix(plugin-list): resolve context tokens in a directly authored object-gallery filter (objectui#10666) ObjectGallery sent its own `filter` on `$filter` with `{current_user_id}` left literal. It now resolves the filter once through core's shared `resolveFilterPlaceholders` against `useFilterScope()`, held by structure with the same hook ListView has used since objectui#10607. That hook moves unchanged from ListView.tsx into a package-internal module so both nodes share it; the package export set does not move. Co-Authored-By: Claude --- .../10666-gallery-filter-context-tokens.md | 7 + packages/plugin-list/src/ListView.tsx | 104 +-------------- packages/plugin-list/src/ObjectGallery.tsx | 26 +++- .../src/useResolvedAuthoredFilter.ts | 120 ++++++++++++++++++ 4 files changed, 150 insertions(+), 107 deletions(-) create mode 100644 .changeset/10666-gallery-filter-context-tokens.md create mode 100644 packages/plugin-list/src/useResolvedAuthoredFilter.ts diff --git a/.changeset/10666-gallery-filter-context-tokens.md b/.changeset/10666-gallery-filter-context-tokens.md new file mode 100644 index 0000000000..b7387da134 --- /dev/null +++ b/.changeset/10666-gallery-filter-context-tokens.md @@ -0,0 +1,7 @@ +--- +'@object-ui/plugin-list': patch +--- + +A directly authored `object-gallery` node now resolves the spec's context tokens in its own `filter` (objectui#10666). With `filter: [['owner', '=', '{current_user_id}']]` the gallery used to send the literal token on `$filter`; it now sends the signed-in user's id, and `{current_org_id}` resolves to the active organization. The filter goes through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the scope `FilterScopeProvider` supplies, the same call `list-view` and `object-grid` have made since objectui#10607. The resolved value is held and compared by structure, so re-rendering with an equal filter issues no extra query, and a change of signed-in user queries again. A filter without placeholders reaches the query unchanged. A gallery rendered as a `list-view` child was already handed a resolved filter, and nothing changes for it. + +Date macros such as `{today}` in a directly authored gallery's filter are now resolved in the browser's local time, as they already are for `list-view`, `object-grid` and `object-view`. Before this change they reached the server as literals, and the server resolved them in the tenant's time zone. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index f4dd609150..014826f63d 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -21,7 +21,8 @@ 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, resolveFilterPlaceholders, type FilterTokenScope } 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 } 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 @@ -691,107 +692,6 @@ 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 []; diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx index a3dcfe369c..55346a6dec 100644 --- a/packages/plugin-list/src/ObjectGallery.tsx +++ b/packages/plugin-list/src/ObjectGallery.tsx @@ -7,13 +7,14 @@ */ import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react'; -import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation } from '@object-ui/react'; +import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope } 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 { /** @@ -412,6 +413,19 @@ export const ObjectGallery: React.FC = (props) => { const fetchesForItself = !!schema.objectName && !boundData && !schema.data && !props.data; const invalidationNonce = useDataInvalidation(fetchesForItself ? schema.objectName : undefined); + // objectui#10666 — the node's own `filter`, with every placeholder + // (`{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 + // 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); + useEffect(() => { let isMounted = true; @@ -478,9 +492,11 @@ export const ObjectGallery: React.FC = (props) => { // verbatim as `$filter`". Typing the adapter above makes // `find`'s parameter real, so the verbatim forward has to // name the parameter's own type instead of riding on - // `unknown`. Asserted, not coerced: the value is passed - // through byte-for-byte, exactly as before. - $filter: schema.filter as QueryParams['$filter'], + // `unknown`. Asserted, not coerced. The value is the + // node's filter with its placeholders resolved + // (objectui#10666); a filter that carries none reaches + // the query structurally unchanged. + $filter: authoredFilter as QueryParams['$filter'], ...(expand.length > 0 ? { $expand: expand } : {}), }); @@ -549,7 +565,7 @@ export const ObjectGallery: React.FC = (props) => { fetchData(); } return () => { isMounted = false; }; - }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data, objectDefReady, objectDef, perms, fetchesForItself, invalidationNonce]); + }, [schema.objectName, dataSource, boundData, schema.data, authoredFilter, props.data, objectDefReady, objectDef, perms, fetchesForItself, invalidationNonce]); const items: Record[] = props.data || boundData || schema.data || fetchedData || []; diff --git a/packages/plugin-list/src/useResolvedAuthoredFilter.ts b/packages/plugin-list/src/useResolvedAuthoredFilter.ts new file mode 100644 index 0000000000..9d7fa15a41 --- /dev/null +++ b/packages/plugin-list/src/useResolvedAuthoredFilter.ts @@ -0,0 +1,120 @@ +/** + * 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. + */ + +/** + * 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. + */ + +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 { + 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. + */ +export 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; +}