Skip to content

Commit a0bef54

Browse files
committed
docs(react): the InlineEditContextValue.draft comment teaches the own-key read
The `draft` member's doc comment told hosts to read a field's live value with a nullish fallback to the saved record. A field the user or a cascade clear emptied is an own draft key with an empty value, so that read handed back the value just removed (the prune loop objectui#7190 measured on the highlights strip). The comment now teaches the staged-record spread: an own draft key wins even when empty, and the saved value is read only when the key is absent. No runtime change. Adds a source-text pin over the comment's code spans, in the pattern of the package's existing doc pins, and a patch changeset for @object-ui/react. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6881e9e commit a0bef54

3 files changed

Lines changed: 163 additions & 1 deletion

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/react': patch
3+
---
4+
5+
Correct the read rule in the `InlineEditContextValue.draft` doc comment (objectui#10466).
6+
7+
It told hosts to read a field's live value as `draft[name] ?? data[name]`. That read
8+
falls back to the saved value when the draft value is `null` or `undefined`. A field the
9+
user emptied is an own draft key with an empty value, and so is a single select or radio
10+
emptied by a cascade clear, which stages `null` since objectui#10291. A host that followed
11+
the comment showed the value the user had just removed, and handed it back to an option
12+
widget that pruned it, which pruned it again on every render (the loop objectui#7190 fixed
13+
on the highlights strip).
14+
15+
The comment now teaches the own-key read: take the value from the staged record
16+
`{ ...data, ...draft }`. An own draft key wins even when its value is empty
17+
(`null`, `undefined`, `''`), and the saved value is read only when the key is absent. The
18+
comment ships in `dist` typings and is what a host author sees on hover. No runtime
19+
behaviour changes. A source-text pin keeps the comment from teaching a fallback read again.

‎packages/react/src/context/InlineEditContext.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,15 @@ export interface InlineEditContextValue {
133133
/**
134134
* Draft of user-edited values. Holds ONLY the keys the user actually
135135
* changed, so the save path never writes computed / read-only / untouched
136-
* fields. Read a field's live value as `draft[name] ?? data[name]`.
136+
* fields. Read a field's live value from the staged record
137+
* `{ ...data, ...draft }`, where `data` is the saved record: an OWN draft
138+
* key wins even when its value is empty (`null`, `undefined`, `''`), and
139+
* `data[name]` is read only when the key is absent. An own key with an
140+
* empty value is a field the user, or a cascade clear, emptied, not an
141+
* untouched one. A read that falls back to the saved value there shows the
142+
* value the user just removed, and hands an option widget that pruned it
143+
* the same value to prune again on every render (objectui#7190,
144+
* objectui#10466).
137145
*/
138146
draft: Record<string, any>;
139147
/** Field to auto-focus when edit was entered from a specific field. */
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10466 — the `draft` member's doc comment on the published
11+
* `InlineEditContextValue` must teach a live-value read in which an OWN draft
12+
* key wins even when its value is empty.
13+
*
14+
* ## The defect
15+
*
16+
* The doc comment prescribed a read that falls back to the saved value
17+
* whenever the draft value is nullish. A field the user emptied, or a cascade
18+
* clear emptied (it stages `null` since objectui#10291), is exactly an own
19+
* draft key with an empty value. A host that copied the published sentence
20+
* therefore showed, and handed its widgets, the value the user had just
21+
* removed; on the record page's highlights strip that read looped an option
22+
* widget's prune forever (objectui#7190). No in-tree host followed the
23+
* sentence. The trap was for the next host author, human or AI, who copies it
24+
* from the `.d.ts` hover.
25+
*
26+
* ## Why a source-text pin
27+
*
28+
* A doc comment is erased before anything runs, so no behavioural test can
29+
* reach it. This takes the same narrow exception the sibling doc pins in this
30+
* package take for prescriptive doc text (`LazyPluginLoader.jsdocExample`,
31+
* `useNavigationOverlay.docExampleRecordSource-7638`).
32+
*
33+
* ## What it asserts, kept deliberately loose
34+
*
35+
* Only the READ RULE taught by the doc comment's code spans, never its prose,
36+
* wording or ordering:
37+
* 1. no code span reads the draft and then falls back through a nullish or
38+
* falsy operator;
39+
* 2. at least one code span names a read that keeps an own draft key.
40+
*
41+
* Both matchers are proven able to fire on synthetic spellings before any
42+
* verdict on the real doc comment is believed.
43+
*/
44+
45+
import { describe, it, expect } from 'vitest';
46+
import { readFileSync } from 'node:fs';
47+
import { dirname, join } from 'node:path';
48+
import { fileURLToPath } from 'node:url';
49+
50+
const HERE = dirname(fileURLToPath(import.meta.url));
51+
/** The context under test, by SOURCE path: the doc comment lives here and ships in `dist` typings. */
52+
const SOURCE = readFileSync(join(HERE, '..', 'InlineEditContext.tsx'), 'utf8');
53+
54+
/**
55+
* The doc comment directly above the `draft:` member of
56+
* `InlineEditContextValue`, with its `*` gutters removed. Anchored on the
57+
* interface so a `draft` member of any other type in the file is never read.
58+
*/
59+
function draftDocComment(): string {
60+
const iface = SOURCE.indexOf('export interface InlineEditContextValue');
61+
if (iface === -1) throw new Error('`export interface InlineEditContextValue` not found; re-anchor this pin.');
62+
const ifaceEnd = SOURCE.indexOf('\n}', iface);
63+
const member = SOURCE.indexOf('\n draft:', iface);
64+
if (member === -1 || member > ifaceEnd) {
65+
throw new Error('`draft:` member not found in `InlineEditContextValue`; re-anchor this pin.');
66+
}
67+
const open = SOURCE.lastIndexOf('/**', member);
68+
const close = SOURCE.indexOf('*/', open);
69+
if (open < iface || close === -1 || close > member || SOURCE.slice(close + 2, member).trim() !== '') {
70+
throw new Error('no doc comment directly above `draft:`; re-anchor this pin.');
71+
}
72+
return SOURCE.slice(open + 3, close)
73+
.split('\n')
74+
.map((line) => line.replace(/^\s*\* ?/, ''))
75+
.join('\n');
76+
}
77+
78+
/** The backtick code spans of a block of prose, whitespace collapsed (a span may wrap a line). */
79+
function codeSpans(text: string): string[] {
80+
return [...text.matchAll(/`([^`]+)`/g)].map((m) => m[1].replace(/\s+/g, ' ').trim());
81+
}
82+
83+
/** A draft read followed by a fallback operator: it returns the saved value for an emptied field. */
84+
const FALLBACK_READ = /\bdraft\b.*(?:\?\?|\|\|)/;
85+
86+
/** Reads that keep an own draft key even when its value is empty. */
87+
const OWN_KEY_READS: readonly RegExp[] = [
88+
// the staged-record spread, draft LAST so its own keys win
89+
/\{\s*\.\.\.(?!draft\b)[\w.?]+\s*,\s*\.\.\.draft\s*\}/,
90+
// an explicit own-key test
91+
/\bin\s+draft\b/,
92+
/\bhasOwn(?:Property\.call)?\(\s*draft\b/,
93+
];
94+
const keepsOwnKey = (span: string): boolean => OWN_KEY_READS.some((re) => re.test(span));
95+
96+
describe('InlineEditContextValue.draft doc comment teaches the own-key read (objectui#10466)', () => {
97+
const doc = draftDocComment();
98+
const spans = codeSpans(doc);
99+
100+
it('LIT CONTROL: the doc comment was read and carries code spans', () => {
101+
// Every verdict below is about these spans. An empty list would make
102+
// "no fallback read" vacuously true.
103+
expect(doc.trim().length).toBeGreaterThan(0);
104+
expect(spans.length).toBeGreaterThan(0);
105+
});
106+
107+
it('CONTROL: the fallback matcher fires on the read objectui#10466 removed, and only on fallbacks', () => {
108+
expect(FALLBACK_READ.test('draft[name] ?? data[name]')).toBe(true);
109+
expect(FALLBACK_READ.test('draft[name] || data[name]')).toBe(true);
110+
expect(FALLBACK_READ.test('{ ...data, ...draft }')).toBe(false);
111+
expect(FALLBACK_READ.test('name in draft ? draft[name] : data[name]')).toBe(false);
112+
});
113+
114+
it('CONTROL: the own-key recogniser accepts own-key reads and refuses the rest', () => {
115+
expect(keepsOwnKey('{ ...data, ...draft }')).toBe(true);
116+
expect(keepsOwnKey('name in draft ? draft[name] : data[name]')).toBe(true);
117+
expect(keepsOwnKey('Object.hasOwn(draft, name) ? draft[name] : data[name]')).toBe(true);
118+
expect(keepsOwnKey('Object.prototype.hasOwnProperty.call(draft, name)')).toBe(true);
119+
// the saved record spread last: the draft never wins
120+
expect(keepsOwnKey('{ ...draft, ...data }')).toBe(false);
121+
expect(keepsOwnKey('draft[name] ?? data[name]')).toBe(false);
122+
});
123+
124+
it('teaches no read that falls back to the saved value on an empty draft value', () => {
125+
expect(
126+
spans.filter((span) => FALLBACK_READ.test(span)),
127+
'A host author copies this read. A fallback on an empty draft value returns the saved ' +
128+
'value for a field the user or a cascade clear emptied (objectui#7190, objectui#10466).',
129+
).toEqual([]);
130+
});
131+
132+
it('names a read in which an own draft key wins', () => {
133+
expect(spans.some(keepsOwnKey), `code spans read: ${JSON.stringify(spans)}`).toBe(true);
134+
});
135+
});

0 commit comments

Comments
 (0)