Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/11699-record-load-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
---

A record page open reads its record once: the page's own `$expand` read is no longer sent a second time when the object definition reaches the page again as a new object (objectui#11699).

Measured on full reloads of a showcase task record page (a real ObjectStack backend and a production console build, 4 reloads per build): the page's `$expand` read of the record (`GET /api/v1/data/OBJ?populate=…`) went from 2 per open, one after the other, to 1. API requests per reload went from 40 to 39. The second read came from a host re-render that handed the page an identical object definition as a new object. The definition was equal to the previous one, so nothing the read sends had changed.

- **The record page keys its record read on what the read sends.** The load effect depended on the object definition, the page and the permission answer as objects. All it reads from them is whether a page exists and the list of relations to expand after field-level security. It now depends on those two values. The record is no longer read again for an equal definition handed down as a new object, for an assigned page that replaces the synthesized default page, or for a permission answer that leaves the list unchanged. A permission answer that denies a relation still reads the record again without that relation, and so does a definition whose relations change.
- **`useMetadata().objects` keeps its identity.** The list and its entries stay the same objects while the stored object and view metadata stay the same. A re-render, or another metadata type landing, no longer rebuilds them. A landed object or view fetch, or an invalidation, still produces a new list that carries the change.

Nothing is added to the package entry: no export, prop, type member or language-pack key. `mergeViewsIntoObjects` and `attachInlineSubforms` keep their signatures.
43 changes: 39 additions & 4 deletions packages/app-shell/src/providers/MetadataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,43 @@ export function attachInlineSubforms(objects: any[]): any[] {
});
}

/**
* The composed object list, keyed on the two STORED arrays it is built from
* (objectui#11699).
*
* The context value's `objects` getter used to run `mergeViewsIntoObjects` +
* `attachInlineSubforms` on every READ, and both hand back a new wrapper for
* every object they touch. So each re-render of a host that reads `objects`
* handed its children a new object for an unchanged definition — measured on a
* showcase record page, where the record-load effect read the record a second
* time for a definition that was JSON-equal, from the same context version.
* AGENTS.md #10: a provider may not republish an equal payload as a new
* object.
*
* The key is the payload, not a React memo: `entry.items` for `object` and for
* `view`. Every write to a cache entry REPLACES that array (a landed fetch, a
* by-name or whole-type invalidation, an org or preview-mode clear), and none
* mutates it in place, so a new array is exactly "this input changed" and the
* same array is exactly "it did not". ⚠️ Nothing re-checks that (AGENTS.md
* #9): it was read off this file and the list's readers once, when this cache
* was written. A write that pushes, splices, sorts or index-assigns into a
* stored `object` / `view` array would make this hand out the list as it was
* — replace the array instead. A discarded `useMemo` around the context
* value cannot move either array, so it cannot move the composed list.
* Module-level and weak, so an entry lives only as long as the stored list it
* was built from.
*/
const COMPOSED_OBJECTS = new WeakMap<unknown[], { views: unknown[]; composed: unknown[] }>();

function composeObjects(objs: unknown[], views: unknown[]): unknown[] {
const cached = COMPOSED_OBJECTS.get(objs);
if (cached && cached.views === views) return cached.composed;
const merged = views.length ? mergeViewsIntoObjects(objs, views) : objs;
const composed = attachInlineSubforms(merged);
COMPOSED_OBJECTS.set(objs, { views, composed });
return composed;
}

function emptyEntry(): TypeCacheEntry {
return {
status: 'idle',
Expand Down Expand Up @@ -1069,11 +1106,9 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:

const base: MetadataContextValue = {
apps: getEntry('app').items,
// The same list for the same stored inputs, read after read (objectui#11699).
get objects() {
const objs = readType(TYPE_BY_STATE_KEY.objects);
const views = readType('view');
const merged = views.length ? mergeViewsIntoObjects(objs, views) : objs;
return attachInlineSubforms(merged);
return composeObjects(readType(TYPE_BY_STATE_KEY.objects), readType('view'));
},
get dashboards() {
return readType(TYPE_BY_STATE_KEY.dashboards);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11699 — the context value's `objects` list is the same list, entry
* for entry, for as long as the stored metadata it is built from is the same.
*
* ## What was measured
*
* On full reloads of a showcase record page (a real ObjectStack backend and a
* production console build), the page read its record a second time, in
* sequence. Instrumented, the record-load effect's second run had changed one
* dependency, `objectDef`, and the new definition was JSON-equal to the old
* one. Both definitions came out of this provider's `objects` getter at the
* SAME context version, and neither was the stored item: the getter ran
* `mergeViewsIntoObjects` + `attachInlineSubforms` on every read, and both
* wrap every object they touch in a new object. So any re-render of a host
* that reads `objects` handed its children new objects for unchanged
* metadata. The record page now keys its read on data (its own pin is
* `RecordDetailView.recordOpenRequests-11699.test.tsx`); this file pins the
* provider half (AGENTS.md #10: "a provider ... may not republish an equal
* payload as a new object").
*
* ## What these pins hold, in both directions
*
* - Re-reading at the same version, and another metadata type landing (a new
* context value), hand back the same list and the same entries.
* - Replacing either stored array — the objects or the views — hands back a
* new list that carries the change. A cache that never let go would pass
* the first half and fail this one.
* - A DISCARDED `useMemo` cache — the context value's own — leaves the list
* where it was. React does not discard on its own in this tree (AGENTS.md
* #10), so the discard is forced by a module-level proxy, the technique and
* the reason of `packages/permissions`' `providerCtxIdentity.discarded.test.tsx`:
* every armed `useMemo` is thrown away, not ones picked by a marker. The
* case first shows the discard reached the provider (the context value IS a
* new object), then that the list did not move. A cache kept in the value's
* own `useMemo` would pass the first two bullets and fail this one.
*
* ⚠️ Unlike that file, `useCallback` is NOT discarded here, and that is a
* measured choice, not an omission: discarding `bump` re-runs the
* provider's preview-mode effect, which lists it as a dependency and, after
* mount, clears the WHOLE metadata cache — so the list empties for a reason
* outside the getter this file pins. That effect's dependency on a
* memoised callback is its own latent AGENTS.md #10 hazard, recorded on
* objectui#11699 and not changed here.
*
* The fixture makes both helpers wrap: `task` has a list view (merged into its
* `listViews`), and its `project` field declares `inlineEdit`, so `project`
* gets a derived form `subforms` entry.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { act, cleanup, render, waitFor } from '@testing-library/react';
import { MetadataProvider, useMetadata } from '../MetadataProvider';

const memoProxy = vi.hoisted(() => ({ armed: false, epoch: 0 }));

type UseMemo = (factory: () => unknown, deps?: unknown[]) => unknown;
type ReactModule = Record<string, unknown> & { useMemo: UseMemo; default?: Record<string, unknown> };

vi.mock('react', async (importOriginal) => {
// Typed loosely on purpose: React's own `useMemo` type takes a
// `DependencyList`, which the patched signature below cannot satisfy.
const actual = await importOriginal<ReactModule>();
const realUseMemo = actual.useMemo;
const patchedUseMemo = (factory: () => unknown, deps?: unknown[]) =>
memoProxy.armed && Array.isArray(deps)
? realUseMemo(factory, [...deps, memoProxy.epoch])
: realUseMemo(factory, deps);
return {
...actual,
useMemo: patchedUseMemo,
default: { ...(actual.default ?? actual), useMemo: patchedUseMemo },
};
});

type Ctx = ReturnType<typeof useMetadata>;

const OBJECTS = [
{
name: 'task',
label: 'Task',
fields: {
title: { type: 'text' },
project: { type: 'master_detail', reference: 'project', inlineEdit: true },
},
},
{ name: 'project', label: 'Project', fields: { name: { type: 'text' } } },
];
const VIEWS = [
{ name: 'task.all', object: 'task', viewKind: 'list', label: 'All', isDefault: true, config: { type: 'grid', columns: ['title'] } },
];
const PAGES = [{ name: 'task_detail', type: 'record', object: 'task' }];

/** Serves each type from `served`, as the network does: a new array per fetch. */
function makeAdapter(served: Record<string, unknown[]>) {
return {
clearCache: vi.fn(),
getClient: () => ({
meta: {
getItems: (type: string) => Promise.resolve({ type, items: structuredClone(served[type] ?? []) }),
getItem: () => Promise.resolve({ item: null }),
},
}),
} as unknown as Parameters<typeof MetadataProvider>[0]['adapter'];
}

function mount(served: Record<string, unknown[]>) {
let latest: Ctx | null = null;
function Probe() {
latest = useMetadata();
return null;
}
const adapter = makeAdapter(served);
const tree = () => (
<MetadataProvider adapter={adapter}>
<Probe />
</MetadataProvider>
);
const utils = render(tree());
return {
ctx: () => latest as unknown as Ctx,
rerender: () => utils.rerender(tree()),
};
}

type Def = { name: string; fields?: Record<string, unknown>; listViews?: Record<string, unknown>; form?: { subforms?: unknown[] } };
const byName = (list: unknown[], name: string) => (list as Def[]).find((o) => o.name === name);

/** Wait until both helpers have had something to wrap. */
async function untilComposed(ctx: () => Ctx) {
await waitFor(() => {
const task = byName(ctx().objects, 'task');
expect(task?.listViews?.['task.all']).toBeTruthy();
expect(byName(ctx().objects, 'project')?.form?.subforms).toHaveLength(1);
});
}

afterEach(() => {
cleanup();
memoProxy.armed = false;
});

describe('MetadataProvider: the composed `objects` list keeps its identity (objectui#11699)', () => {
it('re-reading at the same version hands back the same list and the same entries', async () => {
const m = mount({ object: OBJECTS, view: VIEWS });
await untilComposed(m.ctx);

const ctx = m.ctx();
const first = ctx.objects;
// Two reads in one render — the measured case: one context version, two getter reads.
expect(ctx.objects).toBe(first);
// A host re-render with nothing changed.
m.rerender();
m.rerender();
expect(m.ctx().objects).toBe(first);
expect(byName(m.ctx().objects, 'task')).toBe(byName(first, 'task'));
expect(byName(m.ctx().objects, 'project')).toBe(byName(first, 'project'));
});

it('another metadata type landing (a new context value) hands back the same list', async () => {
const m = mount({ object: OBJECTS, view: VIEWS, page: PAGES });
await untilComposed(m.ctx);
const before = m.ctx();
const first = before.objects;

await act(async () => {
await before.ensureType('page');
});
await waitFor(() => expect(m.ctx().pages).toHaveLength(1));

expect(m.ctx()).not.toBe(before);
expect(m.ctx().objects).toBe(first);
expect(byName(m.ctx().objects, 'task')).toBe(byName(first, 'task'));
});

it('LIVE CONTROL: replacing the stored objects hands back a new list carrying the change', async () => {
const served: Record<string, unknown[]> = { object: OBJECTS, view: VIEWS };
const m = mount(served);
await untilComposed(m.ctx);
const first = m.ctx().objects;

served.object = [
{ ...OBJECTS[0], fields: { ...OBJECTS[0].fields, due: { type: 'date' } } },
OBJECTS[1],
];
await act(async () => {
await m.ctx().refresh('object');
});
await waitFor(() => expect(byName(m.ctx().objects, 'task')?.fields?.due).toBeTruthy());

expect(m.ctx().objects).not.toBe(first);
// Still composed: the merge and the subform pass ran over the new list.
expect(byName(m.ctx().objects, 'task')?.listViews?.['task.all']).toBeTruthy();
expect(byName(m.ctx().objects, 'project')?.form?.subforms).toHaveLength(1);
});

it('LIVE CONTROL: replacing the stored views hands back a new list carrying the change', async () => {
const served: Record<string, unknown[]> = { object: OBJECTS, view: VIEWS };
const m = mount(served);
await untilComposed(m.ctx);
const first = m.ctx().objects;

served.view = [
...VIEWS,
{ name: 'task.mine', object: 'task', viewKind: 'list', label: 'Mine', config: { type: 'grid', columns: ['title'] } },
];
await act(async () => {
await m.ctx().refresh('view');
});
await waitFor(() => expect(byName(m.ctx().objects, 'task')?.listViews?.['task.mine']).toBeTruthy());

expect(m.ctx().objects).not.toBe(first);
});

it('a discarded context-value memo leaves the list where it was (AGENTS.md #10)', async () => {
memoProxy.armed = true;
const m = mount({ object: OBJECTS, view: VIEWS });
await untilComposed(m.ctx);
const before = m.ctx();
const first = before.objects;

// Two discards, each followed by a re-render with nothing changed.
memoProxy.epoch += 1;
m.rerender();
memoProxy.epoch += 1;
m.rerender();

// The discard reached the provider: its context value was rebuilt.
expect(m.ctx()).not.toBe(before);
// And the list it hands out did not move.
expect(m.ctx().objects).toBe(first);
expect(byName(m.ctx().objects, 'task')).toBe(byName(first, 'task'));
expect(byName(m.ctx().objects, 'project')).toBe(byName(first, 'project'));
});
});
Loading
Loading