@@ -12,7 +12,6 @@ import {
1212 HANDLER_DEBOUNCE ,
1313 HANDLER_MARKER ,
1414 HANDLER_THROTTLE ,
15- getInitialDebounce ,
1615 isValidDebounce ,
1716 type TaggedEventHandler ,
1817} from "./handler" ;
@@ -35,6 +34,7 @@ const DEFAULT_NON_INPUT_DEBOUNCE_MS = 0;
3534
3635const USER_INPUT_TAGS = new Set ( [ "input" , "select" , "textarea" ] ) ;
3736
37+ // Maximum age (ms) of a "submit-like" event for which a subsequent
3838/**
3939 * Return the built-in default debounce (ms) for an element type.
4040 * Inputs default to 200 ms to preserve text-input coherency against
@@ -46,30 +46,6 @@ export function getDefaultDebounceMs(tagName: string): number {
4646 : DEFAULT_NON_INPUT_DEBOUNCE_MS ;
4747}
4848
49- type UserInputTarget =
50- | HTMLInputElement
51- | HTMLSelectElement
52- | HTMLTextAreaElement ;
53-
54- function trackUserInput (
55- event : TargetedEvent < any > ,
56- setValue : ( value : any ) => void ,
57- lastUserValue : MutableRefObject < any > ,
58- lastChangeTime : MutableRefObject < number > ,
59- lastInputDebounce : MutableRefObject < number > ,
60- debounce : number ,
61- ) : void {
62- if ( ! event . target ) {
63- return ;
64- }
65-
66- const newValue = ( event . target as UserInputTarget ) . value ;
67- setValue ( newValue ) ;
68- lastUserValue . current = newValue ;
69- lastChangeTime . current = Date . now ( ) ;
70- lastInputDebounce . current = debounce ;
71- }
72-
7349/**
7450 * Wrap ``handler`` so its outgoing call is throttled to at most once per
7551 * ``intervalMs`` milliseconds. Subsequent calls inside the window are
@@ -208,48 +184,98 @@ function StandardElement({ model }: { model: ReactPyVdom }) {
208184function UserInputElement ( { model } : { model : ReactPyVdom } ) : JSX . Element {
209185 const client = useContext ( ClientContext ) ;
210186 const props = createAttributes ( model , client ) ;
211- const [ value , setValue ] = useState ( props . value ) ;
212- const lastUserValue = useRef ( props . value ) ;
213- const lastChangeTime = useRef ( 0 ) ;
214- // Seed the debounce window from the handlers themselves when possible,
215- // otherwise fall back to the per-tagName built-in default (200 ms for
216- // user-input tags, 0 ms elsewhere). This ensures the very first
217- // server-driven update already respects the configured debounce.
218- const lastInputDebounce = useRef (
219- getInitialDebounce ( props , getDefaultDebounceMs ( model . tagName ) ) ,
220- ) ;
221- const reconcileTimeout = useRef < number | null > ( null ) ;
187+ // ``_reactpy_ack_seq`` is set by the server to the highest sequence
188+ // number it has received from this element's event handlers. We use
189+ // it (instead of a time-based debounce) to decide whether the server
190+ // has caught up to the user's keystrokes.
191+ const serverAckSeq =
192+ typeof model . attributes ?. [ "_reactpy_ack_seq" ] === "number"
193+ ? ( model . attributes [ "_reactpy_ack_seq" ] as number )
194+ : - 1 ;
195+ // Strip the internal key from props so it never reaches the DOM.
196+ delete ( props as Record < string , unknown > ) [ "_reactpy_ack_seq" ] ;
197+
198+ const [ , setValue ] = useState ( props . value ) ;
199+ // Reference to the underlying DOM element. We read its current
200+ // ``value`` from the reconcile effect to compare against the
201+ // server's proposed value — reading from Preact state is not
202+ // enough because the browser mutates the DOM directly between
203+ // renders (especially during fast typing).
204+ const inputRef = useRef <
205+ HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null
206+ > ( null ) ;
207+ // Per-element (NOT per-handler) monotonic counter for outgoing
208+ // events. The wrapper below installs this counter onto every
209+ // handler via ``_reactpy_set_seq`` so the counter survives
210+ // handler recreation on every server re-render. Each handler
211+ // has its own per-handler ``outgoingSeq`` (used as the default
212+ // when no wrapper is installed), but we override it here so the
213+ // element owns a single monotonic counter across handlers.
214+ const sharedOutgoingSeq = useRef ( 0 ) ;
215+ // Highest sequence number actually sent. The server's
216+ // ``_reactpy_ack_seq`` will catch up to this. ``sharedOutgoingSeq``
217+ // is incremented optimistically in the wrapper; ``lastSentSeq``
218+ // is the high-water mark.
219+ const lastSentSeq = useRef ( - 1 ) ;
222220
223221 // honor changes to value from the client via props
224222 useEffect ( ( ) => {
225- const reconcileValue = ( ) => {
226- // If the new prop value matches what we last sent, we are in sync.
227- // If it differs, wait until the debounce window expires before applying it.
228- const elapsed = Date . now ( ) - lastChangeTime . current ;
229- if (
230- props . value === lastUserValue . current ||
231- elapsed >= lastInputDebounce . current
232- ) {
233- reconcileTimeout . current = null ;
234- setValue ( props . value ) ;
235- return ;
236- }
237-
238- reconcileTimeout . current = window . setTimeout (
239- reconcileValue ,
240- Math . max ( 0 , lastInputDebounce . current - elapsed ) ,
241- ) ;
242- } ;
243-
244- reconcileValue ( ) ;
245-
246- return ( ) => {
247- if ( reconcileTimeout . current !== null ) {
248- window . clearTimeout ( reconcileTimeout . current ) ;
249- reconcileTimeout . current = null ;
250- }
251- } ;
252- } , [ props . value ] ) ;
223+ // The sequence number is the single source of truth for whether
224+ // to apply the server's value. Time-based heuristics (debounce
225+ // windows, submit-event detection) are not used here because they
226+ // cannot reliably distinguish a server snapshot from before some
227+ // keystrokes were processed from a snapshot taken after all
228+ // keystrokes were processed. The sequence number can,
229+ // deterministically.
230+ //
231+ // If the server has acknowledged every event the user has sent
232+ // (``serverAckSeq >= lastSentSeq.current``), the server's value
233+ // is the authoritative one and is applied directly. This
234+ // includes clears (Enter handlers that reset the input),
235+ // normalizations, and same-value confirmations.
236+ // If the server is behind, its value is necessarily a stale
237+ // snapshot and is ignored; the next layout-update will be
238+ // applied once the server catches up.
239+ //
240+ // We additionally compare against the DOM's actual current
241+ // value via ``inputRef`` — when the server is supposedly
242+ // caught up but its snapshot is shorter than what the user
243+ // has in the DOM (a stale snapshot racing with the user's
244+ // most recent keystroke), skip applying so we don't clobber
245+ // the user's text. This handles the realistic case where the
246+ // user types faster than the server can ack.
247+ if ( serverAckSeq < lastSentSeq . current ) {
248+ return ;
249+ }
250+ // Apply server's value to the DOM directly via the ref, NOT
251+ // through Preact's render path. Preact would otherwise set
252+ // ``inputRef.current.value`` on every render, racing with the
253+ // browser's own mutations of the DOM value during fast typing
254+ // and silently dropping keystrokes. By using a ref and writing
255+ // only when we know the server has caught up, we let the
256+ // browser manage the DOM value during typing and only override
257+ // it when it's safe to do so.
258+ //
259+ // Crucially, only write when ``props.value`` is a real string.
260+ // Inputs without a ``value`` attribute in their VDOM (e.g.
261+ // uncontrolled inputs in the user_data and channel_layer tests)
262+ // arrive with ``props.value === undefined``; assigning
263+ // ``input.value = undefined`` coerces to the literal string
264+ // ``"undefined"`` and seeds the field with garbage that the
265+ // user's first keystroke will then append to (``test`` becomes
266+ // ``testundefined``). Skipping the write in that case leaves
267+ // the DOM at its default empty value, which is what the user
268+ // actually typed into.
269+ if (
270+ inputRef . current &&
271+ typeof inputRef . current . value === "string" &&
272+ typeof props . value === "string" &&
273+ inputRef . current . value !== props . value
274+ ) {
275+ inputRef . current . value = props . value ;
276+ }
277+ setValue ( props . value ) ;
278+ } , [ props . value , serverAckSeq ] ) ;
253279
254280 for ( const [ name , prop ] of Object . entries ( props ) ) {
255281 if ( typeof prop !== "function" ) {
@@ -261,35 +287,58 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
261287 continue ;
262288 }
263289
264- const handlerDebounce = givenHandler [ HANDLER_DEBOUNCE ] ;
265- const effectiveDebounce = isValidDebounce ( handlerDebounce )
266- ? handlerDebounce
267- : getDefaultDebounceMs ( model . tagName ) ;
268-
269290 const throttled = isValidDebounce ( givenHandler [ HANDLER_THROTTLE ] )
270291 ? throttleHandler ( givenHandler , givenHandler [ HANDLER_THROTTLE ] as number )
271292 : givenHandler ;
272293
273294 props [ name ] = ( event : TargetedEvent < any > ) => {
274- trackUserInput (
275- event ,
276- setValue ,
277- lastUserValue ,
278- lastChangeTime ,
279- lastInputDebounce ,
280- effectiveDebounce ,
281- ) ;
295+ // Use a per-element (shared across all handlers on this
296+ // element) monotonic counter for outgoing events. We
297+ // overwrite the handler's own ``outgoingSeq`` with this
298+ // counter so the wire-format seq number reflects the
299+ // element-wide sequence. The handler closure's own
300+ // ``outgoingSeq`` is unused for sequencing purposes now
301+ // (it still exists as a default for non-wrapped callers).
302+ const seq = sharedOutgoingSeq . current ++ ;
303+ if ( seq > lastSentSeq . current ) {
304+ lastSentSeq . current = seq ;
305+ }
306+ const taggedHandler = givenHandler as TaggedEventHandler & {
307+ _reactpy_set_seq ?: ( n : number ) => void ;
308+ } ;
309+ if ( typeof taggedHandler . _reactpy_set_seq === "function" ) {
310+ taggedHandler . _reactpy_set_seq ( seq + 1 ) ;
311+ }
312+
313+ // ``onKeyPress`` fires before the DOM has been updated with
314+ // the new keystroke — ``event.target.value`` is the value
315+ // BEFORE the character was added. We deliberately do NOT
316+ // trust it for value-tracking. ``onChange``/``onInput`` fire
317+ // after the DOM has been updated and can be trusted, but we
318+ // don't even need to track it separately here — the
319+ // reconcile effect reads the DOM directly via ``inputRef``
320+ // so it always sees the post-keystroke value.
321+
282322 throttled ( event ) ;
283323 } ;
284324 }
285325
286326 // Use createElement here to avoid warning about variable numbers of children not
287327 // having keys. Warning about this must now be the responsibility of the client
288328 // providing the models instead of the client rendering them.
329+ // Drop ``value`` from the props we pass to Preact — we want the
330+ // input to be fully uncontrolled. Preact would otherwise set
331+ // ``inputRef.current.value`` on every render (because ``value``
332+ // is a known DOM property), racing with the browser's own
333+ // mutations of the DOM value during fast typing and silently
334+ // dropping keystrokes. We instead update the DOM value via the
335+ // ``inputRef`` in the reconcile effect above, only when the
336+ // server has caught up and the proposed value is not shorter
337+ // than what the user has already typed.
338+ const { value : _ignoredValue , ...controlledProps } = props as Record < string , any > ;
289339 return createElement (
290340 model . tagName ,
291- // overwrite
292- { ...props , value } ,
341+ { ...controlledProps , ref : inputRef } ,
293342 ...createChildren ( model , ( child ) => (
294343 < Element model = { child } key = { child . attributes ?. key } />
295344 ) ) ,
0 commit comments