Skip to content

Commit 0b251cd

Browse files
authored
Codex/textfield native authoritative reconciler (#196)
* test(expo-demo): add TextField input stress harness * fix(rn): make TextField native-authoritative * fix(text-field): close reconciler review gaps
1 parent e5d4563 commit 0b251cd

26 files changed

Lines changed: 818 additions & 19 deletions

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/doc/generated/components/text-field.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ nav_order: 7
99

1010
# Text Field
1111

12-
Visible `TextFieldLabel` composition remains the norm for form fields. A compact control such as topbar search may omit the label only when it supplies `accessibilityLabel`; development builds warn once when neither naming channel is authored. Use a `TextFieldHandle` ref for consumer-owned focus policy. Inputs belong in `<Modal mode="full">`; autofocus when the form subtree mounts, without waiting for the enter animation. For validation, call `ref.current?.focus()` from the invalid branch of the submit handler. The handle exposes only `focus()` and `blur()`—never the raw native input.
12+
Visible `TextFieldLabel` composition remains the norm for form fields. A compact control such as topbar search may omit the label only when it supplies `accessibilityLabel`; development builds warn once when neither naming channel is authored. Native TextField is native-authoritative while typing: controlled `value` echoes acknowledge native edits instead of overwriting the buffer frame by frame. Use `sanitize` only for minimal synchronous character normalization; the function must be referentially stable, inexpensive, and non-throwing. Prefer `maxLength` for length limits because it requires no corrective write, and use `disabled`—not the non-public React Native `editable` prop—to block editing. A sanitizer write can cancel an active IME composition, so verify required keyboards. External rewrites racing a keystroke are user/native-first and may diverge visually for one frame; a later parent commit can request the rewrite again. A target string still present in the bounded pending-emit history is intentionally treated as an echo, the unavoidable ambiguity of the value-only API. Clear by setting the controlled `value` to `""`; it uses the same rewrite path, and no `clear()` handle is exposed. Avoid programmatic writes into focused secure fields. Render-path memoization is separate performance work: it may reduce latency but cannot correct controlled-input ordering. Use a `TextFieldHandle` ref for consumer-owned focus policy. Inputs belong in `<Modal mode="full">`; autofocus when the form subtree mounts, without waiting for the enter animation. For validation, call `ref.current?.focus()` from the invalid branch of the submit handler. The handle exposes only `focus()` and `blur()`—never the raw native input.
1313

1414
## API
1515

@@ -25,6 +25,8 @@ Visible `TextFieldLabel` composition remains the norm for form fields. A compact
2525
| `inputMode` | no | `'text' | 'decimal' | 'numeric' | 'tel' | 'email' | 'url' | 'search'` | input behaviour |
2626
| `secureTextEntry` | no | `boolean` | input behaviour |
2727
| `autoCapitalize` | no | `'none' | 'sentences' | 'words' | 'characters'` | input behaviour |
28+
| `sanitize` | no | `(text: string) => string` | input behaviour |
29+
| `maxLength` | no | `number` | input behaviour |
2830
| `disabled` | no | `boolean` | input behaviour |
2931
| `onFocus` | no | `() => void` | input behaviour |
3032
| `onBlur` | no | `() => void` | input behaviour |

packages/doc/pipeline/component-api-ir.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export const COMPONENT_API_DOCS = [
6666
file: 'packages/rn/generated/components/text-field.ts',
6767
type: 'TextFieldProps',
6868
relatedPrefix: 'TextField',
69-
lead: 'Visible `TextFieldLabel` composition remains the norm for form fields. A compact control such as topbar search may omit the label only when it supplies `accessibilityLabel`; development builds warn once when neither naming channel is authored. Use a `TextFieldHandle` ref for consumer-owned focus policy. Inputs belong in `<Modal mode="full">`; autofocus when the form subtree mounts, without waiting for the enter animation. For validation, call `ref.current?.focus()` from the invalid branch of the submit handler. The handle exposes only `focus()` and `blur()`—never the raw native input.',
69+
lead: 'Visible `TextFieldLabel` composition remains the norm for form fields. A compact control such as topbar search may omit the label only when it supplies `accessibilityLabel`; development builds warn once when neither naming channel is authored. Native TextField is native-authoritative while typing: controlled `value` echoes acknowledge native edits instead of overwriting the buffer frame by frame. Use `sanitize` only for minimal synchronous character normalization; the function must be referentially stable, inexpensive, and non-throwing. Prefer `maxLength` for length limits because it requires no corrective write, and use `disabled`—not the non-public React Native `editable` prop—to block editing. A sanitizer write can cancel an active IME composition, so verify required keyboards. External rewrites racing a keystroke are user/native-first and may diverge visually for one frame; a later parent commit can request the rewrite again. A target string still present in the bounded pending-emit history is intentionally treated as an echo, the unavoidable ambiguity of the value-only API. Clear by setting the controlled `value` to `""`; it uses the same rewrite path, and no `clear()` handle is exposed. Avoid programmatic writes into focused secure fields. Render-path memoization is separate performance work: it may reduce latency but cannot correct controlled-input ordering. Use a `TextFieldHandle` ref for consumer-owned focus policy. Inputs belong in `<Modal mode="full">`; autofocus when the form subtree mounts, without waiting for the enter animation. For validation, call `ref.current?.focus()` from the invalid branch of the submit handler. The handle exposes only `focus()` and `blur()`—never the raw native input.',
7070
},
7171
{
7272
source: 'select-field',

packages/doc/pipeline/docs-drift.test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ const PAGE_CONTRACT = {
156156
'| `placeholder` | no | `string` | input behaviour |',
157157
"| `inputMode` | no | `'text' | 'decimal' | 'numeric' | 'tel' | 'email' | 'url' | 'search'` | input behaviour |",
158158
'| `secureTextEntry` | no | `boolean` | input behaviour |',
159+
'| `sanitize` | no | `(text: string) => string` | input behaviour |',
160+
'| `maxLength` | no | `number` | input behaviour |',
159161
'| `disabled` | no | `boolean` | input behaviour |',
160162
'| `onFocus` | no | `() => void` | input behaviour |',
161163
'| `onBlur` | no | `() => void` | input behaviour |',

packages/expo-demo/src/screens/Menu.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FormSheet, FormSheet2 } from '../sheets/FormSheet';
99
import { ShareAddressSheet } from '../sheets/ShareAddressSheet';
1010
import { SendAddressSheet } from '../sheets/SendAddressSheet';
1111
import { CountryPickerSheet } from '../sheets/CountryPickerSheet';
12+
import { TextFieldStress } from './TextFieldStress';
1213

1314
type FormValues = {
1415
iban: string;
@@ -22,6 +23,7 @@ type FormValues = {
2223
// Sheet state (open flags · the form's values) lives with the screen that
2324
// uses it, not in the router.
2425
export function Menu({ onBack }: { onBack: () => void }) {
26+
const [showTextFieldStress, setShowTextFieldStress] = React.useState(false);
2527
const activity = useSheet();
2628
const amount = useSheet();
2729
const actions = useSheet();
@@ -42,6 +44,10 @@ export function Menu({ onBack }: { onBack: () => void }) {
4244
[],
4345
);
4446

47+
if (showTextFieldStress) {
48+
return <TextFieldStress onBack={() => setShowTextFieldStress(false)} />;
49+
}
50+
4551
return (
4652
<Screen safeArea>
4753
<Topbar>
@@ -57,6 +63,7 @@ export function Menu({ onBack }: { onBack: () => void }) {
5763
<Button size="lg" onPress={shareAddress.show}>Share Bitcoin Address</Button>
5864
<Button size="lg" onPress={sendAddress.show}>Send Bitcoin Address</Button>
5965
<Button size="lg" onPress={countryPicker.show}>Verify Phone · Country Picker</Button>
66+
<Button size="lg" variant="soft" onPress={() => setShowTextFieldStress(true)}>TextField Stress Harness</Button>
6067
</View>
6168

6269
<ActivitySheet open={activity.open} onClose={activity.onClose} />
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import * as React from 'react';
2+
3+
import {
4+
Button,
5+
IconButton,
6+
Screen,
7+
Scroll,
8+
Text,
9+
TextField,
10+
TextFieldLabel,
11+
Topbar,
12+
TopbarLeading,
13+
TopbarTitle,
14+
View,
15+
} from '@ds';
16+
17+
const LOAD_INTERVAL_MS = 48;
18+
const BUSY_LOOP_MS = 24;
19+
const SANITIZE_UPPERCASE_NO_SPACES = (text: string): string =>
20+
text.toUpperCase().replaceAll(' ', '');
21+
22+
function blockJsBriefly(): void {
23+
const deadline = Date.now() + BUSY_LOOP_MS;
24+
while (Date.now() < deadline) {
25+
// Intentionally bounded work: this makes delayed controlled-value echoes
26+
// easier to hit without locking the demo indefinitely.
27+
}
28+
}
29+
30+
export function TextFieldStress({ onBack }: { onBack: () => void }) {
31+
const [plain, setPlain] = React.useState('');
32+
const [sanitized, setSanitized] = React.useState('');
33+
const [loadEnabled, setLoadEnabled] = React.useState(false);
34+
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
35+
36+
React.useEffect(() => {
37+
let cancelled = false;
38+
39+
const cancelTimer = () => {
40+
if (timerRef.current !== null) {
41+
clearTimeout(timerRef.current);
42+
timerRef.current = null;
43+
}
44+
};
45+
46+
if (loadEnabled) {
47+
const run = () => {
48+
if (cancelled) return;
49+
blockJsBriefly();
50+
if (!cancelled) timerRef.current = setTimeout(run, LOAD_INTERVAL_MS);
51+
};
52+
timerRef.current = setTimeout(run, LOAD_INTERVAL_MS);
53+
}
54+
55+
return () => {
56+
cancelled = true;
57+
cancelTimer();
58+
};
59+
}, [loadEnabled]);
60+
61+
const clear = React.useCallback(() => {
62+
setPlain('');
63+
setSanitized('');
64+
}, []);
65+
66+
const reset = React.useCallback(() => {
67+
setLoadEnabled(false);
68+
clear();
69+
}, [clear]);
70+
71+
return (
72+
<Screen safeArea>
73+
<Topbar>
74+
<TopbarLeading>
75+
<IconButton icon="chevron-left" variant="soft" accessibilityLabel="Back to menu" onPress={onBack} />
76+
</TopbarLeading>
77+
<TopbarTitle>TextField stress</TopbarTitle>
78+
</Topbar>
79+
80+
<Scroll>
81+
<View direction="column" align="stretch" gap="lg" paddingX="lg" paddingY="lg">
82+
<View direction="column" align="stretch" gap="sm">
83+
<Text size="md" emphasis>Controlled-input race harness</Text>
84+
<Text size="sm" muted>
85+
Enable JS load, focus each field, then type Lennard as quickly as possible. Compare the native buffer with the visible JS state.
86+
</Text>
87+
<Text size="sm" muted>
88+
JS load is disabled by default. Each cycle blocks for at most {BUSY_LOOP_MS} ms and its timer is cancelled when disabled or unmounted.
89+
</Text>
90+
</View>
91+
92+
<View direction="column" align="stretch" gap="sm">
93+
<TextField value={plain} onChangeText={setPlain} placeholder="Fast-type Lennard">
94+
<TextFieldLabel>Plain controlled TextField</TextFieldLabel>
95+
</TextField>
96+
<Text size="sm" mono>JS state: {JSON.stringify(plain)}</Text>
97+
</View>
98+
99+
<View direction="column" align="stretch" gap="sm">
100+
<TextField
101+
value={sanitized}
102+
onChangeText={setSanitized}
103+
sanitize={SANITIZE_UPPERCASE_NO_SPACES}
104+
placeholder="Fast-type Lennard with spaces"
105+
>
106+
<TextFieldLabel>Uppercase + strip spaces</TextFieldLabel>
107+
</TextField>
108+
<Text size="sm" mono>JS state: {JSON.stringify(sanitized)}</Text>
109+
</View>
110+
111+
<View direction="column" align="stretch" gap="sm">
112+
<Button variant={loadEnabled ? 'solid' : 'soft'} onPress={() => setLoadEnabled((enabled) => !enabled)}>
113+
JS load: {loadEnabled ? 'on' : 'off'}
114+
</Button>
115+
<Button variant="soft" onPress={clear}>Clear fields</Button>
116+
<Button variant="soft" onPress={reset}>Reset harness</Button>
117+
</View>
118+
</View>
119+
</Scroll>
120+
</Screen>
121+
);
122+
}

packages/prototype/factory/factory.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,11 +698,13 @@ function renderInput(node, ns, ctx) {
698698
if (props.inputMode !== undefined) host.setAttribute('input-mode', String(props.inputMode));
699699
if (props.secureTextEntry) host.setAttribute('secure-text-entry', '');
700700
if (props.autoCapitalize !== undefined) host.setAttribute('auto-capitalize', String(props.autoCapitalize));
701+
if (props.maxLength !== undefined) host.setAttribute('max-length', String(props.maxLength));
701702
if (props.disabled) host.setAttribute('disabled', '');
702703
const label = behaviour.labelPart ? ctx.content?.[behaviour.labelPart] : undefined;
703704
const accessibleName = props.accessibilityLabel ?? plainTextContent(label);
704705
if (accessibleName !== undefined) host.setAttribute('aria-label', String(accessibleName));
705706
if (typeof props.onChangeText === 'function') host.onChangeText = props.onChangeText;
707+
if (typeof props.sanitize === 'function') host.sanitize = props.sanitize;
706708
if (typeof props.onFocus === 'function') host.onNuriFocus = props.onFocus;
707709
if (typeof props.onBlur === 'function') host.onNuriBlur = props.onBlur;
708710
return host;
@@ -946,6 +948,7 @@ export function defineNuriComponent(descriptor, tagName, options = {}) {
946948
inputMode: 'input-mode',
947949
secureTextEntry: 'secure-text-entry',
948950
autoCapitalize: 'auto-capitalize',
951+
maxLength: 'max-length',
949952
disabled: 'disabled',
950953
accessibilityLabel: 'aria-label',
951954
};
@@ -1107,7 +1110,7 @@ export function defineNuriComponent(descriptor, tagName, options = {}) {
11071110
}
11081111
if (inputBehaviour) {
11091112
for (const prop of inputBehaviour.props || []) {
1110-
if (prop === 'onChangeText' || prop === 'onFocus' || prop === 'onBlur') {
1113+
if (prop === 'onChangeText' || prop === 'onFocus' || prop === 'onBlur' || prop === 'sanitize') {
11111114
if (typeof this[prop] === 'function') props[prop] = this[prop].bind(this);
11121115
continue;
11131116
}

packages/prototype/factory/factory.test.js

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,7 @@ test('C5 · <nuri-text-field> input props reach the native input and label names
631631
assert.ok(customElements.get('nuri-text-field'), 'TextField web twin is registered');
632632
assert.deepEqual(
633633
[...customElements.get('nuri-text-field').observedAttributes].sort(),
634-
['accent', 'aria-label', 'auto-capitalize', 'disabled', 'input-mode', 'placeholder', 'secure-text-entry', 'size', 'value'],
634+
['accent', 'aria-label', 'auto-capitalize', 'disabled', 'input-mode', 'max-length', 'placeholder', 'secure-text-entry', 'size', 'value'],
635635
'TextField observes its input allowlist attrs plus accent',
636636
);
637637

@@ -693,9 +693,11 @@ test('C6 · <nuri-text-field> secure/disabled/button delegation and aria-label o
693693
assert.equal(iconAction.getAttribute('aria-label'), 'Hide account number');
694694
});
695695

696-
test('C7 · <nuri-text-field> native input event calls onChangeText property handler', async () => {
696+
test('C7 · <nuri-text-field> sanitizes through a function property and forwards maxLength', async () => {
697697
const field = dom.window.document.createElement('nuri-text-field');
698698
field.setAttribute('auto-capitalize', 'words');
699+
field.setAttribute('max-length', '12');
700+
field.sanitize = (value) => value.toUpperCase().replaceAll(' ', '');
699701
const seen = [];
700702
field.onChangeText = (value) => seen.push(value);
701703
field.innerHTML = '<nuri-text-field-label>First name</nuri-text-field-label>';
@@ -704,9 +706,29 @@ test('C7 · <nuri-text-field> native input event calls onChangeText property han
704706

705707
const input = field.querySelector('nuri-input > input');
706708
assert.equal(input.getAttribute('autocapitalize'), 'words');
707-
input.value = 'Ada';
709+
assert.equal(input.maxLength, 12);
710+
assert.equal(field.hasAttribute('sanitize'), false, 'sanitize stays a function property, never an HTML attribute');
711+
input.value = 'Ada Lovelace';
708712
input.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
709-
assert.deepEqual(seen, ['Ada']);
713+
assert.equal(input.value, 'ADALOVELACE');
714+
assert.deepEqual(seen, ['ADALOVELACE']);
715+
});
716+
717+
test('C7a · <nuri-text-field> maps a mid-string selection through sanitization', async () => {
718+
const field = dom.window.document.createElement('nuri-text-field');
719+
field.sanitize = (value) => value.replaceAll(' ', '');
720+
field.innerHTML = '<nuri-text-field-label>Reference</nuri-text-field-label>';
721+
mount(field);
722+
await tick();
723+
724+
const input = field.querySelector('nuri-input > input');
725+
input.value = 'ab cd';
726+
input.setSelectionRange(3, 4);
727+
input.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
728+
729+
assert.equal(input.value, 'abcd');
730+
assert.equal(input.selectionStart, 2);
731+
assert.equal(input.selectionEnd, 3);
710732
});
711733

712734
test('C6b · <nuri-text-field-icon-button> requires aria-label before delegating an icon-only button', () => {

packages/prototype/generated/descriptors/text-field.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ export const textFieldDescriptor = {
9898
'inputMode',
9999
'secureTextEntry',
100100
'autoCapitalize',
101+
'sanitize',
102+
'maxLength',
101103
'disabled',
102104
'onFocus',
103105
'onBlur',

0 commit comments

Comments
 (0)