Skip to content

Commit 5211ddb

Browse files
committed
add TCP-like event sequencing to retain order
1 parent 0f9a41c commit 5211ddb

5 files changed

Lines changed: 204 additions & 87 deletions

File tree

src/js/packages/@reactpy/client/src/components.tsx

Lines changed: 128 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -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

3635
const 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 }) {
208184
function 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
)),

src/js/packages/@reactpy/client/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export type ReactPyComponent = ComponentType<{ model: ReactPyVdom }>;
4848

4949
export type ReactPyVdom = {
5050
tagName: string;
51-
attributes?: { [key: string]: string };
51+
attributes?: { [key: string]: any };
5252
children?: (ReactPyVdom | string)[];
5353
error?: string;
5454
eventHandlers?: { [key: string]: ReactPyVdomEventHandler };

src/js/packages/@reactpy/client/src/vdom.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,14 @@ function createEventHandler(
222222
throttle,
223223
}: ReactPyVdomEventHandler,
224224
): [string, TaggedEventHandler] {
225+
// Sequence number for the next outgoing event on this handler.
226+
// The wrapper in ``UserInputElement`` may overwrite this before
227+
// the event is actually sent (so that the wrapper, not this
228+
// closure, owns the per-element monotonic counter and survives
229+
// handler recreation on every server re-render). The default
230+
// behavior (no wrapper) is to use a per-handler counter starting
231+
// at 0.
232+
let outgoingSeq = 0;
225233
const eventHandler = function (...args: any[]) {
226234
const data = Array.from(args).map((value) => {
227235
const event = value as Event;
@@ -239,9 +247,17 @@ function createEventHandler(
239247
return event;
240248
}
241249
});
242-
client.sendMessage({ type: "layout-event", data, target });
243-
} as TaggedEventHandler;
250+
const seq = outgoingSeq++;
251+
client.sendMessage({ type: "layout-event", data, target, seq });
252+
} as TaggedEventHandler & {
253+
_reactpy_peek_seq?: () => number;
254+
_reactpy_set_seq?: (n: number) => void;
255+
};
244256
eventHandler[HANDLER_MARKER] = true;
257+
eventHandler._reactpy_peek_seq = (): number => outgoingSeq;
258+
eventHandler._reactpy_set_seq = (n: number): void => {
259+
outgoingSeq = n;
260+
};
245261
if (debounce !== undefined) {
246262
if (!isValidDebounce(debounce)) {
247263
log.warn(

0 commit comments

Comments
 (0)