From 2218bbbbbd188dbf7425ef192e65c8468f44aa64 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:25:02 +0000 Subject: [PATCH 1/7] fix(plugin-view): resolve context tokens in the filters ObjectView hands on ObjectView had no read of @object-ui/core's resolveFilterPlaceholders, so a named list view filtering `owner` on `{current_user_id}` reached the query with the literal token on the registered object-view node, while the app-shell host resolved the same view. The three authored filter segments (the active view's filter, table.filter, table.defaultFilters) are now resolved once through the shared resolver, against the session scope the host provides through useFilterScope, and the non-grid find(), the object-grid schema and the delegated list-view schema all read the resolved segments. The resolved value is held against its raw inputs (compared by structure) and the scope's members, so ObjectGrid and ListView, which key their fetch on the filter's identity, do not refetch per render, and a `{now}` macro cannot mint a new value per render. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- packages/plugin-view/src/ObjectView.tsx | 126 +++++++- ...View.namedViewContextTokens-10506.test.tsx | 281 ++++++++++++++++++ 2 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index f8fba1e43b..d88a38b3c0 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -22,7 +22,7 @@ * - ViewSwitcher for toggling between view types */ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import type { ObjectViewSchema, ObjectGridSchema, @@ -74,13 +74,15 @@ import { columnIdentity, convertSortToQueryParams, recordDelete, + resolveFilterPlaceholders, + type FilterTokenScope, } from '@object-ui/core'; -import { SchemaRenderer as ImportedSchemaRenderer, useSettledSchema, notifyDataChanged } from '@object-ui/react'; +import { SchemaRenderer as ImportedSchemaRenderer, useSettledSchema, notifyDataChanged, useFilterScope } from '@object-ui/react'; import type { HandleClickModifiers } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { ViewSwitcher } from './ViewSwitcher'; import { deriveRecordSurface } from './recordSurface'; -import { useStableIdentity } from './stableIdentity'; +import { useStableIdentity, isStructurallyEqual } from './stableIdentity'; /** * SchemaRenderer from @object-ui/react, used to render sub-view schemas. @@ -238,6 +240,77 @@ function viewColumnFieldNames(columns: unknown): string[] | undefined { return columns.map(columnIdentity).filter((n): n is string => !!n); } +/** + * The three authored filter segments this component chains, in precedence + * order: the active view's own filter (a named `listViews` entry, else the + * host's `views` entry), then the object-level `table.filter`, then its + * deprecated alias `table.defaultFilters`. + */ +interface AuthoredFilterSegments { + view: any[] | undefined; + table: ObjectGridSchema['filter']; + tableDefaults: ObjectGridSchema['defaultFilters']; +} + +/** + * objectui#10506 — resolve every filter placeholder in the authored filter + * segments ONCE, through `@object-ui/core`'s shared `resolveFilterPlaceholders`, + * against the session scope the host provides (`useFilterScope`). + * + * This is the one point where the segments meet, and all three doors a filter + * leaves this component by read from its result: the non-grid `find()`, the + * `object-grid` schema handed to `ObjectGrid`, and the `list-view` schema handed + * to a host's `renderListView`. Before it, this component had no read of the + * resolver at all, so `{ owner: '{current_user_id}' }` in a named view reached + * the query as the literal token while the app-shell host resolved it. + * + * ⛔ 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 rather than recomputed per render. + * `ObjectGrid` and `ListView` both key their fetch on the filter's identity, 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 holding + * against the OUTPUT (compare, keep the old reference if equal) cannot stop + * that either. The key is the raw segments, compared by structure — a host that + * rebuilds an equal `views` array inline must not re-resolve (objectui#6460) — + * plus the scope's members read one by one, never the scope object's identity + * (AGENTS.md #10). Like `useStableIdentity`, the ref is derived from its inputs + * alone, so StrictMode's double render returns the same reference. + */ +function useResolvedFilterSegments( + segments: AuthoredFilterSegments, + scope: FilterTokenScope, +): AuthoredFilterSegments { + const held = useRef<{ + segments: AuthoredFilterSegments; + currentUserId: FilterTokenScope['currentUserId']; + currentOrgId: FilterTokenScope['currentOrgId']; + onUnresolved: FilterTokenScope['onUnresolved']; + resolved: AuthoredFilterSegments; + } | null>(null); + const prev = held.current; + if ( + prev + && prev.currentUserId === scope.currentUserId + && prev.currentOrgId === scope.currentOrgId + && prev.onUnresolved === scope.onUnresolved + && isStructurallyEqual(prev.segments, segments) + ) { + return prev.resolved; + } + const resolved = resolveFilterPlaceholders(segments, scope); + held.current = { + segments, + currentUserId: scope.currentUserId, + currentOrgId: scope.currentOrgId, + onUnresolved: scope.onUnresolved, + resolved, + }; + return resolved; +} + /** * Record-create verb, shared with the runtime object pages: both surfaces * resolve `console.objectView.new` ("New" / 新建) so the Studio grid toolbar @@ -966,6 +1039,20 @@ export const ObjectView: React.FC = ({ : undefined, ); + // objectui#10506 — the authored filter segments, with every placeholder + // resolved once against the host's session scope (see + // `useResolvedFilterSegments`). The non-grid fetch, the grid schema and the + // delegated `list-view` schema below all read THESE, never the raw segments. + const filterScope = useFilterScope(); + const authoredFilters = useResolvedFilterSegments( + { + view: currentNamedViewConfig?.filter || activeViewQueryInputs?.filter, + table: schema.table?.filter, + tableDefaults: schema.table?.defaultFilters, + }, + filterScope, + ); + // Current view type from named view, multi-view prop, or default const currentViewType: string = useMemo(() => { if (currentNamedViewConfig?.type) return currentNamedViewConfig.type; @@ -1046,9 +1133,12 @@ export const ObjectView: React.FC = ({ // The `table` segment reads the CANONICAL key first and the deprecated // one only as its alias (objectui#5102). The two view segments ahead of // it are untouched — this extends the last segment only. + // + // objectui#10506: the segments are read RESOLVED — `{current_user_id}` + // and the other placeholders expanded once, above, through the shared + // `resolveFilterPlaceholders` — so this query carries the real id. const finalFilter = mergeFilterNodes( - currentNamedViewConfig?.filter || activeViewQueryInputs?.filter - || schema.table?.filter || schema.table?.defaultFilters, + authoredFilters.view || authoredFilters.table || authoredFilters.tableDefaults, ); // objectui#4869: this was the LAST object-bound read site handing an @@ -1181,7 +1271,7 @@ export const ObjectView: React.FC = ({ }, [ schema.objectName, dataSource, currentViewType, refreshKey, currentNamedViewConfig, activeViewQueryInputs, renderListView, - objectSchemaReady, objectSchema, perms, + objectSchemaReady, objectSchema, perms, authoredFilters, ]); // Determine layout mode. #2578: default the record surface from how heavy the @@ -2019,7 +2109,11 @@ export const ObjectView: React.FC = ({ // such asymmetry to preserve: its canonical slot is the only one left // (the legacy `defaultSort` is retired, objectui#5861), and it is the one // that can hold more than a single key. - const viewFilter = currentNamedViewConfig?.filter || activeView?.filter; + // + // objectui#10506: all three filter segments are read RESOLVED (see + // `authoredFilters`), and held while their inputs are unchanged — ObjectGrid + // keys its fetch on `schema.filter`'s identity. + const viewFilter = authoredFilters.view; const viewSort = currentNamedViewConfig?.sort || activeView?.sort; return { @@ -2039,14 +2133,14 @@ export const ObjectView: React.FC = ({ ...operations, create: false, // Create is handled by the view's create button }, - defaultFilters: viewFilter || schema.table?.defaultFilters, + defaultFilters: viewFilter || authoredFilters.tableDefaults, // Canonical `table` keys, at last forwarded. `filter` carries the // `table` segment ONLY: the view segment resolved above already occupies // the legacy slot, and ObjectGrid prefers this slot over that one — so // handing it `table.filter` while a named view is active would let the // table default outrank the view, inverting the precedence the two // untouched segments exist to express. - filter: viewFilter ? undefined : schema.table?.filter, + filter: viewFilter ? undefined : authoredFilters.table, // `sort` carries the WHOLE chain instead — view segments first, then the // `table` one. Same precedence as `mergedSort` and the non-grid fetch // express; what changes is only WHICH slot a view's sort arrives in, and @@ -2072,7 +2166,7 @@ export const ObjectView: React.FC = ({ selectable: schema.table?.selectable, className: schema.table?.className, }; - }, [schema, operations, currentNamedViewConfig, activeView]); + }, [schema, operations, currentNamedViewConfig, activeView, authoredFilters]); // Build form schema const buildFormSchema = (): ObjectFormSchema => { @@ -2214,10 +2308,14 @@ export const ObjectView: React.FC = ({ // the maintainer ruling of 2026-08-22 REJECTED (quoted with the non-grid // fetch above), because that slot legitimately also carries `$orderby`'s own // `Record` map. - const mergedFilters = currentNamedViewConfig?.filter - || activeView?.filter - || schema.table?.filter - || schema.table?.defaultFilters; + // + // objectui#10506: the filter chain reads the RESOLVED segments, so the + // delegated renderer receives real ids. The app-shell host resolves this + // value again in its own `renderListView`; a resolved id no longer matches + // the whole-token pattern, so that second pass changes nothing. + const mergedFilters = authoredFilters.view + || authoredFilters.table + || authoredFilters.tableDefaults; const mergedSort = currentNamedViewConfig?.sort || activeView?.sort diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx new file mode 100644 index 0000000000..338230f81a --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx @@ -0,0 +1,281 @@ +/** + * 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#10506 — the registered `object-view` node resolves the spec's + * context tokens (`{current_user_id}`, `{current_org_id}`) in the filters it + * hands on, through `@object-ui/core`'s ONE shared `resolveFilterPlaceholders`, + * with the session scope the host provides (`FilterScopeProvider`, which the + * console shell mounts from the signed-in user and the active organization). + * + * Before this change `ObjectView` had zero reads of that resolver, so a + * per-user named view — `{ field: 'owner', operator: 'equals', value: + * '{current_user_id}' }` — reached the query with the literal token, while the + * same view under the app-shell host was resolved. + * + * An authored filter leaves this component through three doors, and each has + * a describe block below: the non-grid `find()` this component issues itself, + * the `object-grid` schema it hands `ObjectGrid`, and the `list-view` schema it + * hands a host's `renderListView`. The two delegated doors also pin that the + * resolved value keeps its reference while nothing changed: `ObjectGrid` and + * `ListView` both key their fetch on the filter's identity, so a copy minted + * on every render would refetch on every render. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, waitFor, act } from '@testing-library/react'; +import { FilterScopeProvider } from '@object-ui/react'; +import { resolveFilterPlaceholders } from '@object-ui/core'; +import { ObjectView } from '../ObjectView'; +import type { ObjectViewSchema } from '@object-ui/types'; + +const { gridSchemas } = vi.hoisted(() => ({ gridSchemas: [] as any[] })); + +vi.mock('@object-ui/react', async (importOriginal) => { + const React = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) =>
{schema?.type}
, + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectGrid: ({ schema }: any) => { + gridSchemas.push(schema); + return
; + }, +})); +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: () =>
, +})); + +const USER = 'usr_42'; +const ORG = 'org_7'; + +const MINE = [{ field: 'owner', operator: 'equals', value: '{current_user_id}' }]; + +function makeAdapter() { + return { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), + }; +} + +/** An `object-view` node whose active view is the named view `mine`. */ +function namedView(type: 'calendar' | 'grid', filter: any[]): ObjectViewSchema { + return { + type: 'object-view', + objectName: 'task', + defaultListView: 'mine', + listViews: { mine: { label: 'Mine', type, filter } }, + } as ObjectViewSchema; +} + +function Scoped({ user = USER, org = ORG, children }: { user?: string | null; org?: string | null; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** The `$filter` of the Nth `find()` this component issued. */ +async function queriedFilter(find: ReturnType, call = 0) { + await waitFor(() => expect(find.mock.calls.length).toBeGreaterThan(call)); + return find.mock.calls[call][1]?.$filter; +} + +let warn: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + gridSchemas.length = 0; + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + warn.mockRestore(); +}); + +describe('named view filter → the non-grid query this component issues (objectui#10506)', () => { + it('queries with the signed-in user id, not the literal {current_user_id}', async () => { + const ds = makeAdapter(); + render(); + expect(await queriedFilter(ds.find)).toEqual([['owner', 'equals', USER]]); + }); + + it('queries with the active organization id for {current_org_id}', async () => { + const ds = makeAdapter(); + const schema = namedView('calendar', [{ field: 'org', operator: 'equals', value: '{current_org_id}' }]); + render(); + expect(await queriedFilter(ds.find)).toEqual([['org', 'equals', ORG]]); + }); + + it('CONTROL: a literal value passes through unchanged', async () => { + const ds = makeAdapter(); + const schema = namedView('calendar', [{ field: 'owner', operator: 'equals', value: 'usr_literal' }]); + render(); + expect(await queriedFilter(ds.find)).toEqual([['owner', 'equals', 'usr_literal']]); + }); + + it('keeps an unresolvable token literal with no scope mounted, and the shared resolver names it', async () => { + // The resolver's own rule, not a fallback invented here: a known token with + // no value in scope is left intact (the query matches nothing rather than + // silently widening) and the resolver warns once, naming the token. + const ds = makeAdapter(); + render(); + expect(await queriedFilter(ds.find)).toEqual([['owner', 'equals', '{current_user_id}']]); + expect(warn.mock.calls.some((c) => 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 = makeAdapter(); + const schema = namedView('calendar', MINE); + const { rerender } = render(); + expect(await queriedFilter(ds.find, 0)).toEqual([['owner', 'equals', USER]]); + + await act(async () => { + rerender(); + }); + expect(await queriedFilter(ds.find, 1)).toEqual([['owner', 'equals', 'usr_99']]); + }); + + it('issues ONE query across parent re-renders that rebuild an equal token filter (no churn from resolving)', async () => { + // A host that writes its `views` inline hands a fresh filter object on every + // render (objectui#6460). Resolving must not turn that into a fresh + // resolved copy per render, or every parent render would re-query. + const ds = makeAdapter(); + const schema = { type: 'object-view', objectName: 'task', defaultViewType: 'calendar' } as ObjectViewSchema; + const ui = (tick: number) => ( + +
+ ({ ...r }))] }]} + /> +
+
+ ); + const { rerender } = render(ui(0)); + expect(await queriedFilter(ds.find)).toEqual([['owner', 'equals', USER]]); + for (let tick = 1; tick <= 3; tick++) { + await act(async () => { + rerender(ui(tick)); + await new Promise((r) => setTimeout(r, 20)); + }); + } + expect(ds.find).toHaveBeenCalledTimes(1); + }); +}); + +describe('named view / table filter → the `object-grid` schema handed to ObjectGrid (objectui#10506)', () => { + it('hands ObjectGrid the resolved named-view filter', async () => { + const ds = makeAdapter(); + render(); + await waitFor(() => expect(gridSchemas.length).toBeGreaterThan(0)); + expect(gridSchemas.at(-1).defaultFilters).toEqual([{ field: 'owner', operator: 'equals', value: USER }]); + }); + + it('hands ObjectGrid the resolved canonical table.filter', async () => { + const ds = makeAdapter(); + const schema = { + type: 'object-view', + objectName: 'task', + table: { filter: [['owner', '=', '{current_user_id}']] }, + } as unknown as ObjectViewSchema; + render(); + await waitFor(() => expect(gridSchemas.length).toBeGreaterThan(0)); + expect(gridSchemas.at(-1).filter).toEqual([['owner', '=', USER]]); + }); + + it('keeps the resolved filter’s reference across re-renders that change nothing', async () => { + // `ObjectGrid` keys its fetch on `schema.filter` BY IDENTITY, so a resolved + // copy minted per render would refetch per render. + const ds = makeAdapter(); + const schema = { + type: 'object-view', + objectName: 'task', + table: { filter: [['owner', '=', '{current_user_id}']] }, + } as unknown as ObjectViewSchema; + const { rerender } = render(); + await waitFor(() => expect(gridSchemas.length).toBeGreaterThan(0)); + const first = gridSchemas.at(-1).filter; + await act(async () => { + rerender(); + }); + await act(async () => { + rerender(); + }); + expect(gridSchemas.length).toBeGreaterThan(1); + expect(first).toEqual([['owner', '=', USER]]); + expect(gridSchemas.at(-1).filter).toBe(first); + }); +}); + +describe('named view / table filter → the `list-view` schema handed to renderListView (objectui#10506)', () => { + function renderDelegated(schema: ObjectViewSchema) { + const seen: any[] = []; + const ds = makeAdapter(); + const renderListView = ({ schema: s }: any) => { + seen.push(s); + return
; + }; + const ui = (s: ObjectViewSchema) => ( + + ); + const utils = render(ui(schema)); + return { seen, rerender: (s: ObjectViewSchema) => utils.rerender(ui(s)) }; + } + + it('hands the delegated slot the resolved named-view filter', () => { + const { seen } = renderDelegated(namedView('grid', MINE)); + expect(seen.at(-1)?.filter).toEqual([{ field: 'owner', operator: 'equals', value: USER }]); + }); + + it('hands the delegated slot a resolved object table.defaultFilters', () => { + const { seen } = renderDelegated({ + type: 'object-view', + objectName: 'task', + table: { defaultFilters: { owner: '{current_user_id}', org: '{current_org_id}' } }, + } as unknown as ObjectViewSchema); + expect(seen.at(-1)?.filter).toEqual({ owner: USER, org: ORG }); + }); + + it('hands over a fixed point of the shared resolver, so the app-shell host’s second pass changes nothing', () => { + // The app-shell host renders through this component and resolves + // `viewDef.filter ?? listSchema.filter` again in its own `renderListView`. + // A resolved id no longer matches the whole-token pattern, so the second + // pass is a no-op even under a different scope. + const { seen } = renderDelegated(namedView('grid', MINE)); + const handed = seen.at(-1)?.filter; + expect(resolveFilterPlaceholders(handed, { currentUserId: 'someone_else', onUnresolved: null })).toEqual(handed); + }); + + it('keeps the resolved filter’s reference across re-renders that change nothing', () => { + // `ListView` keys its fetch on `schema.filter` BY IDENTITY, and the Studio + // host spreads this slot straight into it. + const schema = namedView('grid', MINE); + const { seen, rerender } = renderDelegated(schema); + const first = seen.at(-1)?.filter; + act(() => rerender(schema)); + act(() => rerender({ ...schema })); + expect(seen.length).toBeGreaterThan(1); + expect(first).toEqual([{ field: 'owner', operator: 'equals', value: USER }]); + expect(seen.at(-1)?.filter).toBe(first); + }); +}); From fa4f23b3f6673f0a002284d0783c34678e5b3640 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:26:45 +0000 Subject: [PATCH 2/7] docs(plugin-view): say the object-view node resolves filter placeholders The schema reference said resolving `{current_user_id}` on the object-view node path was still open; it is resolved there now. The plugin-view README and plugin page gain one paragraph beside the filter-tier precedence saying every tier's filter goes through resolveFilterPlaceholders, where the scope comes from, and what an unresolvable token does. Adds the plugin-view patch changeset. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10506-named-view-context-tokens.md | 30 +++++++++++++++++++ content/docs/api/schema-reference.md | 2 +- content/docs/plugins/plugin-view.mdx | 10 +++++++ packages/plugin-view/README.md | 15 +++++++++- 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 .changeset/10506-named-view-context-tokens.md diff --git a/.changeset/10506-named-view-context-tokens.md b/.changeset/10506-named-view-context-tokens.md new file mode 100644 index 0000000000..2d539bf3e5 --- /dev/null +++ b/.changeset/10506-named-view-context-tokens.md @@ -0,0 +1,30 @@ +--- +'@object-ui/plugin-view': patch +--- + +`ObjectView` resolves the spec's context tokens in the filters it hands on (objectui#10506). + +A per-user named list view, `{ field: 'owner', operator: 'equals', value: '{current_user_id}' }`, +reached the query with the literal token on the registered `object-view` node: this component +had no read of `@object-ui/core`'s `resolveFilterPlaceholders`, while the app-shell host resolved +the same view. + +The three authored filter segments (the active view's filter, `table.filter`, +`table.defaultFilters`) are now resolved 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). All three paths a filter leaves this +component by read the resolved segments: the query a non-grid view issues, the `object-grid` +schema handed to `ObjectGrid`, and the `list-view` schema handed to a host's `renderListView`. +The relative-date macros (`{today}`, `{current_quarter_start}`, …) resolve in the same call, +as they already did on every other surface. + +A token the scope cannot resolve is left as written and the resolver warns once, which is the +resolver's own rule: with no `FilterScopeProvider` mounted, a view filtered on +`{current_user_id}` still matches no record rather than every record. + +The resolved value is held against its raw inputs, compared by structure, and the scope's two +ids. `ObjectGrid` and `ListView` key their fetch on the filter's identity, so a host that +re-renders without changing anything does not trigger a refetch, and a `{now}` macro does not +produce a new value on every render. The app-shell host resolves the delegated filter a second +time in its own `renderListView`; a resolved id no longer matches the placeholder pattern, so +that pass changes nothing. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 04f4a3b885..70f64d15cc 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -907,7 +907,7 @@ A complete object management interface combining grid, form, search, filters, an } ``` -`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the host that renders the list resolves it to the signed-in user's id before the query runs, and resolving it on the `object-view` node path itself is tracked in objectui#10506. +`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the `object-view` node resolves it to the signed-in user's id before the query runs (objectui#10506), through the same `resolveFilterPlaceholders` from `@object-ui/core` that every other surface calls, with the user taken from the nearest `FilterScopeProvider` (`@object-ui/react`; the console shell mounts one). With no provider mounted the token is left as written, so the view matches no record rather than every record. | Property | Type | Description | |----------|------|-------------| diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index 6fbbd1d10f..32e3739492 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -194,6 +194,16 @@ then `table.filter`/`table.sort`, then `table.defaultFilters` (filter only — `sort` has no legacy tier since `table.defaultSort` was retired). If you never write `listViews`, that first tier never applies. +Whichever tier wins, its filter goes through `resolveFilterPlaceholders` from +`@object-ui/core` before it leaves `ObjectView` (objectui#10506), on all three +paths: the query a non-grid view issues, the grid, and a host's +`renderListView`. So the spec's context tokens (`{current_user_id}`, +`{current_org_id}`) and the relative-date macros (`{today}`, +`{current_quarter_start}`, …) arrive as real values. The user and organization +come from the nearest `FilterScopeProvider` (`@object-ui/react`), which the +console shell mounts; with none mounted a context token is left as written and +the resolver warns, so the view matches no record rather than every record. + `pagination` and `selection` have no such tier, and no effect outside the grid: `defaultViewType: 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map'` don't page or multi-select, so `ObjectView` never forwards diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index 32055fea03..8fea9bff03 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -253,7 +253,20 @@ predates this change: an **active named view's own** `filter` / `sort` `table`. In order, highest first: the active named view's `filter`/`sort`, then `table.filter`/`table.sort`, then `table.defaultFilters` (filter only — `sort` has no legacy tier since `table.defaultSort` was retired). (If you -never write `listViews`, that first tier never applies.) `pagination` and `selection` have no such tier, and no effect +never write `listViews`, that first tier never applies.) + +Whichever tier wins, its filter goes through `resolveFilterPlaceholders` from +`@object-ui/core` before it leaves `ObjectView` (objectui#10506), on all three +paths: the query a non-grid view issues, the grid, and a host's +`renderListView`. So the spec's context tokens (`{current_user_id}`, +`{current_org_id}`) and the relative-date macros (`{today}`, +`{current_quarter_start}`, …) arrive as real values. The user and organization +come from the nearest `FilterScopeProvider` (`@object-ui/react`), which the +console shell mounts; with none mounted a context token is left as written and +the resolver warns, so the view matches no record rather than every record. +`src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx` pins each path. + +`pagination` and `selection` have no such tier, and no effect outside the grid — the non-grid renderers don't page or multi-select, so `ObjectView` never forwards either spelling to them. From ec06abc381d55e3e23e85613056d0d5a83cf02b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:33:31 +0000 Subject: [PATCH 3/7] test(plugin-view): type the warn-call parameter in the 10506 pin tsc -p tsconfig.test.json refused the implicit any (TS7006). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../__tests__/ObjectView.namedViewContextTokens-10506.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx index 338230f81a..f33caa0ec0 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx @@ -139,7 +139,7 @@ describe('named view filter → the non-grid query this component issues (object const ds = makeAdapter(); render(); expect(await queriedFilter(ds.find)).toEqual([['owner', 'equals', '{current_user_id}']]); - expect(warn.mock.calls.some((c) => String(c[0]).includes('{current_user_id}'))).toBe(true); + 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 () => { From bf6a8fbe9ccf6e484551c234708bd32e2a555810 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:43:57 +0000 Subject: [PATCH 4/7] refactor(plugin-view): hold the resolved filter segments in state, not a ref Reading and writing a ref during render drew eleven react-hooks/refs warnings, which the lint config keeps only for code that predates the rule. React's own pattern for a value derived from previous renders is a state pair set during render; ObjectView.tsx lints at its base warning count again. Also types the view segment through NamedListView rather than a bare any. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- packages/plugin-view/src/ObjectView.tsx | 52 ++++++++++++++----------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index d88a38b3c0..2c79127091 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -22,7 +22,7 @@ * - ViewSwitcher for toggling between view types */ -import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { ObjectViewSchema, ObjectGridSchema, @@ -247,7 +247,7 @@ function viewColumnFieldNames(columns: unknown): string[] | undefined { * deprecated alias `table.defaultFilters`. */ interface AuthoredFilterSegments { - view: any[] | undefined; + view: NamedListView['filter']; table: ObjectGridSchema['filter']; tableDefaults: ObjectGridSchema['defaultFilters']; } @@ -276,39 +276,47 @@ interface AuthoredFilterSegments { * that either. The key is the raw segments, compared by structure — a host that * rebuilds an equal `views` array inline must not re-resolve (objectui#6460) — * plus the scope's members read one by one, never the scope object's identity - * (AGENTS.md #10). Like `useStableIdentity`, the ref is derived from its inputs - * alone, so StrictMode's double render returns the same reference. + * (AGENTS.md #10). The held pair lives in state, not in a ref read during + * render, so the value handed out is always the one React committed. */ function useResolvedFilterSegments( segments: AuthoredFilterSegments, scope: FilterTokenScope, ): AuthoredFilterSegments { - const held = useRef<{ - segments: AuthoredFilterSegments; - currentUserId: FilterTokenScope['currentUserId']; - currentOrgId: FilterTokenScope['currentOrgId']; - onUnresolved: FilterTokenScope['onUnresolved']; - resolved: AuthoredFilterSegments; - } | null>(null); - const prev = held.current; + const [held, setHeld] = useState(() => resolveFilterSegments(segments, scope)); if ( - prev - && prev.currentUserId === scope.currentUserId - && prev.currentOrgId === scope.currentOrgId - && prev.onUnresolved === scope.onUnresolved - && isStructurallyEqual(prev.segments, segments) + held.currentUserId !== scope.currentUserId + || held.currentOrgId !== scope.currentOrgId + || held.onUnresolved !== scope.onUnresolved + || !isStructurallyEqual(held.segments, segments) ) { - return prev.resolved; + // 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 = resolveFilterSegments(segments, scope); + setHeld(next); + return next.resolved; } - const resolved = resolveFilterPlaceholders(segments, scope); - held.current = { + return held.resolved; +} + +/** One resolution, remembered with the inputs it was computed from. */ +interface HeldFilterSegments { + segments: AuthoredFilterSegments; + currentUserId: FilterTokenScope['currentUserId']; + currentOrgId: FilterTokenScope['currentOrgId']; + onUnresolved: FilterTokenScope['onUnresolved']; + resolved: AuthoredFilterSegments; +} + +function resolveFilterSegments(segments: AuthoredFilterSegments, scope: FilterTokenScope): HeldFilterSegments { + return { segments, currentUserId: scope.currentUserId, currentOrgId: scope.currentOrgId, onUnresolved: scope.onUnresolved, - resolved, + resolved: resolveFilterPlaceholders(segments, scope), }; - return resolved; } /** From f0173ec3fa97ba95253362ebe1d48d01b18a47c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 10:21:08 +0000 Subject: [PATCH 5/7] fix(core): the placeholder walks return a non-plain object as a leaf resolveContextTokens and resolveDateMacros rebuilt every object from its own keys, so a Date comparand, which the spec admits, came back from resolveFilterPlaceholders as an empty object. Routing plugin-view's filters through the shared resolver made that reachable on the object-view node. Only a plain object (prototype Object.prototype or null) is walked now; anything else is returned as the same instance. Pins: a core test (a Date survives as the same instance through resolveFilterPlaceholders and through each walk alone; plain and null-prototype objects carrying a token are still resolved) and one more plugin-view cell (a Date in a named-view filter reaches find() as a Date). The changeset gains the core patch line. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10506-named-view-context-tokens.md | 6 ++ .../filter-tokens.dateLeaf-10506.test.ts | 75 +++++++++++++++++++ packages/core/src/utils/date-macros.ts | 6 ++ packages/core/src/utils/filter-tokens.ts | 6 ++ ...View.namedViewContextTokens-10506.test.tsx | 15 ++++ 5 files changed, 108 insertions(+) create mode 100644 packages/core/src/utils/__tests__/filter-tokens.dateLeaf-10506.test.ts diff --git a/.changeset/10506-named-view-context-tokens.md b/.changeset/10506-named-view-context-tokens.md index 2d539bf3e5..a036dc4f18 100644 --- a/.changeset/10506-named-view-context-tokens.md +++ b/.changeset/10506-named-view-context-tokens.md @@ -1,5 +1,6 @@ --- '@object-ui/plugin-view': patch +'@object-ui/core': patch --- `ObjectView` resolves the spec's context tokens in the filters it hands on (objectui#10506). @@ -28,3 +29,8 @@ re-renders without changing anything does not trigger a refetch, and a `{now}` m produce a new value on every render. The app-shell host resolves the delegated filter a second time in its own `renderListView`; a resolved id no longer matches the placeholder pattern, so that pass changes nothing. + +`@object-ui/core`'s two placeholder walks (`resolveContextTokens` and `resolveDateMacros`, both behind +`resolveFilterPlaceholders`) now return a non-plain object as the same instance instead of rebuilding +it from its own keys, so a `Date` comparand, which the spec admits, no longer comes back as `{}`; +plain and null-prototype objects are still walked. diff --git a/packages/core/src/utils/__tests__/filter-tokens.dateLeaf-10506.test.ts b/packages/core/src/utils/__tests__/filter-tokens.dateLeaf-10506.test.ts new file mode 100644 index 0000000000..44c64cdde0 --- /dev/null +++ b/packages/core/src/utils/__tests__/filter-tokens.dateLeaf-10506.test.ts @@ -0,0 +1,75 @@ +/** + * 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. + */ + +import { describe, expect, it } from 'vitest'; +import { resolveFilterPlaceholders, resolveContextTokens } from '../filter-tokens'; +import { resolveDateMacros } from '../date-macros'; + +/** + * objectui#10506 — the placeholder walks return a NON-plain object as a leaf. + * + * `resolveContextTokens` and `resolveDateMacros` walked every + * `typeof value === 'object'` as a bag and rebuilt it from `Object.keys`. A + * `Date` has no own keys, so a `Date` comparand came back as an empty object: + * `[['due', '>=', someDate]]` resolved to `[['due', '>=', {}]]`, a filter that + * compares against nothing. The spec admits `Date` as a comparand + * (`ACCEPTED_FILTER_COMPARAND_TYPES`; `$gte` and its siblings declare + * `z.date()`), and the resolver's own header promised "arrays and plain + * objects" only. + * + * "Plain" is a prototype of `Object.prototype` or `null`. Both are still + * walked; the two controls below hold that half, so a fix that stopped + * walking objects altogether would go red here too. + */ +describe('objectui#10506 — a Date comparand survives the placeholder walks', () => { + const SCOPE = { currentUserId: 'usr_42', currentOrgId: 'org_7', onUnresolved: null }; + + it('resolveFilterPlaceholders keeps a Date comparand as the SAME instance', () => { + const due = new Date('2026-01-01T00:00:00Z'); + const out = resolveFilterPlaceholders( + [['due', '>=', due], ['owner', '=', '{current_user_id}']], + SCOPE, + ) as unknown[][]; + expect(out[0][2]).toBe(due); + // The token beside it is still resolved in the same pass. + expect(out[1][2]).toBe('usr_42'); + }); + + it('keeps a Date inside an operator map as the SAME instance', () => { + const due = new Date('2026-01-01T00:00:00Z'); + const out = resolveFilterPlaceholders({ due: { $gte: due }, owner: '{current_user_id}' }, SCOPE) as { + due: { $gte: unknown }; + owner: unknown; + }; + expect(out.due.$gte).toBe(due); + expect(out.owner).toBe('usr_42'); + }); + + it('each walk on its own returns the Date untouched (both hunks are needed)', () => { + const due = new Date('2026-01-01T00:00:00Z'); + expect((resolveDateMacros([['due', '>=', due]]) as unknown[][])[0][2]).toBe(due); + expect((resolveContextTokens([['due', '>=', due]], SCOPE) as unknown[][])[0][2]).toBe(due); + }); + + it('CONTROL: a plain object carrying a token is still walked and resolved', () => { + expect(resolveFilterPlaceholders({ owner: '{current_user_id}', org: '{current_org_id}' }, SCOPE)).toEqual({ + owner: 'usr_42', + org: 'org_7', + }); + }); + + it('CONTROL: a null-prototype object carrying a token is still walked and resolved', () => { + const bag = Object.assign(Object.create(null) as Record, { + owner: '{current_user_id}', + nested: Object.assign(Object.create(null) as Record, { org: '{current_org_id}' }), + }); + const out = resolveFilterPlaceholders(bag, SCOPE) as Record; + expect(out.owner).toBe('usr_42'); + expect(out.nested.org).toBe('org_7'); + }); +}); diff --git a/packages/core/src/utils/date-macros.ts b/packages/core/src/utils/date-macros.ts index 85aa5da1d7..09a1abc526 100644 --- a/packages/core/src/utils/date-macros.ts +++ b/packages/core/src/utils/date-macros.ts @@ -177,7 +177,13 @@ export function resolveDateMacros(filter: T, now: Date = new Date()): T return touched ? replaced : value; } if (Array.isArray(value)) return value.map(walk); + // Only a PLAIN object (prototype `Object.prototype` or `null`) is walked; + // any other object — a `Date` comparand above all — is returned as the + // same instance rather than rebuilt from its own keys into `{}` + // (objectui#10506). if (typeof value === 'object') { + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) return value; const out: Record = {}; for (const k of Object.keys(value)) out[k] = walk((value as any)[k]); return out; diff --git a/packages/core/src/utils/filter-tokens.ts b/packages/core/src/utils/filter-tokens.ts index 4d05d23e16..baca3333ab 100644 --- a/packages/core/src/utils/filter-tokens.ts +++ b/packages/core/src/utils/filter-tokens.ts @@ -209,7 +209,13 @@ export function resolveContextTokens(filter: T, scope: FilterTokenScope } if (Array.isArray(value)) return value.map(walk); + // Only a PLAIN object (prototype `Object.prototype` or `null`) is a bag to + // walk. Anything else is a leaf, returned as the same instance: rebuilding + // a `Date` from its own keys — it has none — turned a comparand the spec + // admits (`ACCEPTED_FILTER_COMPARAND_TYPES`) into `{}` (objectui#10506). if (typeof value === 'object') { + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) return value; const out: Record = {}; for (const k of Object.keys(value)) out[k] = walk((value as any)[k]); return out; diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx index f33caa0ec0..9d22d67b9c 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx @@ -132,6 +132,21 @@ describe('named view filter → the non-grid query this component issues (object expect(await queriedFilter(ds.find)).toEqual([['owner', 'equals', 'usr_literal']]); }); + it('keeps a Date comparand a Date on its way to find() (the resolver returns non-plain objects as leaves)', async () => { + // The spec admits `Date` as a comparand. Before `@object-ui/core`'s walks + // treated a non-plain object as a leaf, routing this filter through the + // shared resolver rebuilt the Date from its (zero) own keys into `{}`. + const ds = makeAdapter(); + const due = new Date('2026-01-01T00:00:00Z'); + const schema = namedView('calendar', [['due', '>=', due], ['owner', '=', '{current_user_id}']]); + render(); + const filter = await queriedFilter(ds.find); + const leaf = Array.isArray(filter?.[0]) ? filter[0][2] : filter?.[2]; + expect(leaf).toBeInstanceOf(Date); + expect((leaf as Date).getTime()).toBe(due.getTime()); + expect(JSON.stringify(filter)).toContain(USER); + }); + it('keeps an unresolvable token literal with no scope mounted, and the shared resolver names it', async () => { // The resolver's own rule, not a fallback invented here: a known token with // no value in scope is left intact (the query matches nothing rather than From 57eda69be50d35fac4cda4ea05060f358bab846f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 11:04:31 +0000 Subject: [PATCH 6/7] docs(plugin-view): make the 10506 changeset and docs prose true to the review Prose only, from the contract review of the head before this commit: - the date macros resolve "as they already did" only on the surfaces that call the resolver (app-shell host, ObjectChart, dashboard widgets), not on every surface; the server-side UTC difference is stated; - an unresolved token never widens the filter, and what the literal selects depends on the backend (ObjectStack resolves it when signed in, refuses it with a 400 otherwise; a backend with no resolver matches no record); - the hold compares three scope members, not two ids; - a non-plain object, a class instance included, is a leaf of both walks; - the console's app-shell renderListView resolves the view's own filter itself, so its date macros and signed-out fallback are unchanged, and the one difference there is a warning from this component's pass. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10506-named-view-context-tokens.md | 38 +++++++++++++------ content/docs/api/schema-reference.md | 2 +- content/docs/plugins/plugin-view.mdx | 4 +- packages/plugin-view/README.md | 4 +- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.changeset/10506-named-view-context-tokens.md b/.changeset/10506-named-view-context-tokens.md index a036dc4f18..777bb912c8 100644 --- a/.changeset/10506-named-view-context-tokens.md +++ b/.changeset/10506-named-view-context-tokens.md @@ -16,21 +16,37 @@ scope the host provides through `useFilterScope` (the console shell mounts `Filt from the signed-in user and the active organization). All three paths a filter leaves this component by read the resolved segments: the query a non-grid view issues, the `object-grid` schema handed to `ObjectGrid`, and the `list-view` schema handed to a host's `renderListView`. -The relative-date macros (`{today}`, `{current_quarter_start}`, …) resolve in the same call, -as they already did on every other surface. +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 +itself, as the ObjectStack server does in UTC, a browser whose local day differs from UTC 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: with no `FilterScopeProvider` mounted, a view filtered on -`{current_user_id}` still matches no record rather than every record. +resolver's own rule, so the filter is never widened. With no `FilterScopeProvider` mounted, what a +literal `{current_user_id}` then selects depends on the backend: the ObjectStack server resolves +it for a signed-in request and refuses it with a 400 otherwise, and a backend with no resolver of +its own matches no record. -The resolved value is held against its raw inputs, compared by structure, and the scope's two -ids. `ObjectGrid` and `ListView` key their fetch on the filter's identity, so a host that -re-renders without changing anything does not trigger a refetch, and a `{now}` macro does not -produce a new value on every render. The app-shell host resolves the delegated filter a second -time in its own `renderListView`; a resolved id no longer matches the placeholder pattern, so -that pass changes nothing. +The resolved value is held against its inputs: the raw segments, compared by structure, and the +scope's three members (`currentUserId`, `currentOrgId`, `onUnresolved`), each compared on its own. +`ObjectGrid` and `ListView` key their fetch on the filter's identity, so a host that re-renders +without changing anything does not trigger a refetch. Date macros are resolved once and held until +the user, the organization or a segment changes, so `{now}` and `{today}` do not produce a new +value on every render of this component: that governs the query a non-grid view issues, the grid, +and a `renderListView` host that queries with the filter it is handed. The console's app-shell +host is not such a host: its `renderListView` resolves the active view's own filter itself +(`viewDef.filter ?? listSchema.filter`, with no filter segment in the schema it gives this +component), so its date macros and its `'current-user'` fallback for a signed-out user are +unchanged. A host that resolves the handed value again, as the app-shell's fallback would, changes +nothing: a resolved id or date no longer matches the placeholder pattern. With no user in scope, +this component's pass warns; the app-shell's own pass substitutes `'current-user'` silently, as +before, so the console's query is unchanged and the one difference is that warning. `@object-ui/core`'s two placeholder walks (`resolveContextTokens` and `resolveDateMacros`, both behind `resolveFilterPlaceholders`) now return a non-plain object as the same instance instead of rebuilding it from its own keys, so a `Date` comparand, which the spec admits, no longer comes back as `{}`; -plain and null-prototype objects are still walked. +plain and null-prototype objects are still walked. A non-plain object is a leaf whatever it is: a +class instance with own keys carrying a token is also returned as the same instance, no longer +copied and resolved. The contract review of this change found no in-repo caller that passes one. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 70f64d15cc..be3d3ca9cc 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -907,7 +907,7 @@ A complete object management interface combining grid, form, search, filters, an } ``` -`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the `object-view` node resolves it to the signed-in user's id before the query runs (objectui#10506), through the same `resolveFilterPlaceholders` from `@object-ui/core` that every other surface calls, with the user taken from the nearest `FilterScopeProvider` (`@object-ui/react`; the console shell mounts one). With no provider mounted the token is left as written, so the view matches no record rather than every record. +`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the `object-view` node resolves it to the signed-in user's id before the query runs (objectui#10506), through the same `resolveFilterPlaceholders` from `@object-ui/core` that the app-shell host, the charts and the dashboard widgets call, with the user taken from the nearest `FilterScopeProvider` (`@object-ui/react`; the console shell mounts one). With no provider mounted the token is left as written and the filter is never widened: the ObjectStack server resolves the literal token for a signed-in request and refuses it with a 400 otherwise, and a backend with no resolver of its own matches no record. | Property | Type | Description | |----------|------|-------------| diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index 32e3739492..5924e0a6f7 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -202,7 +202,9 @@ paths: the query a non-grid view issues, the grid, and a host's `{current_quarter_start}`, …) arrive as real values. The user and organization come from the nearest `FilterScopeProvider` (`@object-ui/react`), which the console shell mounts; with none mounted a context token is left as written and -the resolver warns, so the view matches no record rather than every record. +the resolver warns. The filter is never widened: the ObjectStack server resolves +the literal token for a signed-in request and refuses it with a 400 otherwise, +and a backend with no resolver of its own matches no record. `pagination` and `selection` have no such tier, and no effect outside the grid: `defaultViewType: 'kanban' | 'gallery' | 'calendar' | 'timeline' | diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index 8fea9bff03..abd4e55150 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -263,7 +263,9 @@ paths: the query a non-grid view issues, the grid, and a host's `{current_quarter_start}`, …) arrive as real values. The user and organization come from the nearest `FilterScopeProvider` (`@object-ui/react`), which the console shell mounts; with none mounted a context token is left as written and -the resolver warns, so the view matches no record rather than every record. +the resolver warns. The filter is never widened: the ObjectStack server resolves +the literal token for a signed-in request and refuses it with a 400 otherwise, +and a backend with no resolver of its own matches no record. `src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx` pins each path. `pagination` and `selection` have no such tier, and no effect From 53aa3660c98f1b99d59aea13b8ab906f2d6d444c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 11:21:15 +0000 Subject: [PATCH 7/7] docs(plugin-view): state the ObjectStack auth and timezone facts correctly Prose only, from the delta review of the previous head: - a signed-out request is refused at the REST auth gate with a 401 before the filter is read; the resolver's 400 is reachable only where a guest context reaches the engine; - the server resolves date macros in the tenant's configured localization.timezone, UTC only by default; - the changeset names the views prop as the route by which the console's active-view filter still reaches ObjectView. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10506-named-view-context-tokens.md | 13 +++++++------ content/docs/api/schema-reference.md | 2 +- content/docs/plugins/plugin-view.mdx | 6 ++++-- packages/plugin-view/README.md | 6 ++++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.changeset/10506-named-view-context-tokens.md b/.changeset/10506-named-view-context-tokens.md index 777bb912c8..44e882cca7 100644 --- a/.changeset/10506-named-view-context-tokens.md +++ b/.changeset/10506-named-view-context-tokens.md @@ -20,14 +20,15 @@ The relative-date macros (`{today}`, `{current_quarter_start}`, …) resolve 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 -itself, as the ObjectStack server does in UTC, a browser whose local day differs from UTC can now -get a different day. +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. With no `FilterScopeProvider` mounted, what a literal `{current_user_id}` then selects depends on the backend: the ObjectStack server resolves -it for a signed-in request and refuses it with a 400 otherwise, and a backend with no resolver of -its own matches no record. +it for a signed-in request and refuses the request otherwise (the REST face answers 401 at its +auth gate before the filter is read, and where a guest context reaches the engine the resolver +answers 400), and a backend with no resolver of its own matches no record. The resolved value is held against its inputs: the raw segments, compared by structure, and the scope's three members (`currentUserId`, `currentOrgId`, `onUnresolved`), each compared on its own. @@ -38,8 +39,8 @@ value on every render of this component: that governs the query a non-grid view and a `renderListView` host that queries with the filter it is handed. The console's app-shell host is not such a host: its `renderListView` resolves the active view's own filter itself (`viewDef.filter ?? listSchema.filter`, with no filter segment in the schema it gives this -component), so its date macros and its `'current-user'` fallback for a signed-out user are -unchanged. A host that resolves the handed value again, as the app-shell's fallback would, changes +component; the active view's filter still reaches this component through the `views` prop), so +its date macros and its `'current-user'` fallback for a signed-out user are unchanged. A host that resolves the handed value again, as the app-shell's fallback would, changes nothing: a resolved id or date no longer matches the placeholder pattern. With no user in scope, this component's pass warns; the app-shell's own pass substitutes `'current-user'` silently, as before, so the console's query is unchanged and the one difference is that warning. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index be3d3ca9cc..f0c97b229e 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -907,7 +907,7 @@ A complete object management interface combining grid, form, search, filters, an } ``` -`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the `object-view` node resolves it to the signed-in user's id before the query runs (objectui#10506), through the same `resolveFilterPlaceholders` from `@object-ui/core` that the app-shell host, the charts and the dashboard widgets call, with the user taken from the nearest `FilterScopeProvider` (`@object-ui/react`; the console shell mounts one). With no provider mounted the token is left as written and the filter is never widened: the ObjectStack server resolves the literal token for a signed-in request and refuses it with a 400 otherwise, and a backend with no resolver of its own matches no record. +`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the `object-view` node resolves it to the signed-in user's id before the query runs (objectui#10506), through the same `resolveFilterPlaceholders` from `@object-ui/core` that the app-shell host, the charts and the dashboard widgets call, with the user taken from the nearest `FilterScopeProvider` (`@object-ui/react`; the console shell mounts one). With no provider mounted the token is left as written and the filter is never widened: the ObjectStack server resolves the literal token for a signed-in request and refuses the request otherwise (the REST face answers 401 at its auth gate before the filter is read, and where a guest context reaches the engine the resolver answers 400), and a backend with no resolver of its own matches no record. | Property | Type | Description | |----------|------|-------------| diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index 5924e0a6f7..694138e4f8 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -203,8 +203,10 @@ paths: the query a non-grid view issues, the grid, and a host's come from the nearest `FilterScopeProvider` (`@object-ui/react`), which the console shell mounts; with none mounted a context token is left as written and the resolver warns. The filter is never widened: the ObjectStack server resolves -the literal token for a signed-in request and refuses it with a 400 otherwise, -and a backend with no resolver of its own matches no record. +the literal token for a signed-in request and refuses the request otherwise +(the REST face answers 401 at its auth gate before the filter is read, and +where a guest context reaches the engine the resolver answers 400), and a +backend with no resolver of its own matches no record. `pagination` and `selection` have no such tier, and no effect outside the grid: `defaultViewType: 'kanban' | 'gallery' | 'calendar' | 'timeline' | diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index abd4e55150..4037d60309 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -264,8 +264,10 @@ paths: the query a non-grid view issues, the grid, and a host's come from the nearest `FilterScopeProvider` (`@object-ui/react`), which the console shell mounts; with none mounted a context token is left as written and the resolver warns. The filter is never widened: the ObjectStack server resolves -the literal token for a signed-in request and refuses it with a 400 otherwise, -and a backend with no resolver of its own matches no record. +the literal token for a signed-in request and refuses the request otherwise +(the REST face answers 401 at its auth gate before the filter is read, and +where a guest context reaches the engine the resolver answers 400), and a +backend with no resolver of its own matches no record. `src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx` pins each path. `pagination` and `selection` have no such tier, and no effect