Skip to content

Commit c8f8e1b

Browse files
committed
wire up an optimistic input to fix react cursor issues
1 parent ac85035 commit c8f8e1b

4 files changed

Lines changed: 119 additions & 96 deletions

File tree

tsunami/demo/githubaction/static/tw.css

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -260,33 +260,12 @@
260260
.sticky {
261261
position: sticky;
262262
}
263-
.-inset-1 {
264-
inset: calc(var(--spacing) * -1);
265-
}
266263
.isolate {
267264
isolation: isolate;
268265
}
269266
.isolation-auto {
270267
isolation: auto;
271268
}
272-
.\!container {
273-
width: 100% !important;
274-
@media (width >= 40rem) {
275-
max-width: 40rem !important;
276-
}
277-
@media (width >= 48rem) {
278-
max-width: 48rem !important;
279-
}
280-
@media (width >= 64rem) {
281-
max-width: 64rem !important;
282-
}
283-
@media (width >= 80rem) {
284-
max-width: 80rem !important;
285-
}
286-
@media (width >= 96rem) {
287-
max-width: 96rem !important;
288-
}
289-
}
290269
.container {
291270
width: 100%;
292271
@media (width >= 40rem) {
@@ -920,18 +899,10 @@
920899
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
921900
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
922901
}
923-
.ring {
924-
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
925-
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
926-
}
927902
.inset-ring {
928903
--tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);
929904
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
930905
}
931-
.outline {
932-
outline-style: var(--tw-outline-style);
933-
outline-width: 1px;
934-
}
935906
.blur {
936907
--tw-blur: blur(8px);
937908
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
@@ -941,18 +912,10 @@
941912
--tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));
942913
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
943914
}
944-
.grayscale {
945-
--tw-grayscale: grayscale(100%);
946-
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
947-
}
948915
.invert {
949916
--tw-invert: invert(100%);
950917
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
951918
}
952-
.sepia {
953-
--tw-sepia: sepia(100%);
954-
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
955-
}
956919
.filter {
957920
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
958921
}
@@ -980,11 +943,6 @@
980943
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
981944
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
982945
}
983-
.transition {
984-
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
985-
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
986-
transition-duration: var(--tw-duration, var(--default-transition-duration));
987-
}
988946
.transition-colors {
989947
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
990948
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -1204,11 +1162,6 @@
12041162
inherits: false;
12051163
initial-value: 0 0 #0000;
12061164
}
1207-
@property --tw-outline-style {
1208-
syntax: "*";
1209-
inherits: false;
1210-
initial-value: solid;
1211-
}
12121165
@property --tw-blur {
12131166
syntax: "*";
12141167
inherits: false;
@@ -1342,7 +1295,6 @@
13421295
--tw-ring-offset-width: 0px;
13431296
--tw-ring-offset-color: #fff;
13441297
--tw-ring-offset-shadow: 0 0 #0000;
1345-
--tw-outline-style: solid;
13461298
--tw-blur: initial;
13471299
--tw-brightness: initial;
13481300
--tw-contrast: initial;

tsunami/demo/tsunamiconfig/static/tw.css

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -251,33 +251,12 @@
251251
.sticky {
252252
position: sticky;
253253
}
254-
.-inset-1 {
255-
inset: calc(var(--spacing) * -1);
256-
}
257254
.isolate {
258255
isolation: isolate;
259256
}
260257
.isolation-auto {
261258
isolation: auto;
262259
}
263-
.\!container {
264-
width: 100% !important;
265-
@media (width >= 40rem) {
266-
max-width: 40rem !important;
267-
}
268-
@media (width >= 48rem) {
269-
max-width: 48rem !important;
270-
}
271-
@media (width >= 64rem) {
272-
max-width: 64rem !important;
273-
}
274-
@media (width >= 80rem) {
275-
max-width: 80rem !important;
276-
}
277-
@media (width >= 96rem) {
278-
max-width: 96rem !important;
279-
}
280-
}
281260
.container {
282261
width: 100%;
283262
@media (width >= 40rem) {
@@ -859,18 +838,10 @@
859838
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
860839
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
861840
}
862-
.ring {
863-
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
864-
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
865-
}
866841
.inset-ring {
867842
--tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);
868843
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
869844
}
870-
.outline {
871-
outline-style: var(--tw-outline-style);
872-
outline-width: 1px;
873-
}
874845
.blur {
875846
--tw-blur: blur(8px);
876847
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
@@ -880,18 +851,10 @@
880851
--tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));
881852
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
882853
}
883-
.grayscale {
884-
--tw-grayscale: grayscale(100%);
885-
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
886-
}
887854
.invert {
888855
--tw-invert: invert(100%);
889856
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
890857
}
891-
.sepia {
892-
--tw-sepia: sepia(100%);
893-
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
894-
}
895858
.filter {
896859
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
897860
}
@@ -919,11 +882,6 @@
919882
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
920883
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
921884
}
922-
.transition {
923-
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
924-
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
925-
transition-duration: var(--tw-duration, var(--default-transition-duration));
926-
}
927885
.transition-colors {
928886
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
929887
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -1153,11 +1111,6 @@
11531111
inherits: false;
11541112
initial-value: 0 0 #0000;
11551113
}
1156-
@property --tw-outline-style {
1157-
syntax: "*";
1158-
inherits: false;
1159-
initial-value: solid;
1160-
}
11611114
@property --tw-blur {
11621115
syntax: "*";
11631116
inherits: false;
@@ -1291,7 +1244,6 @@
12911244
--tw-ring-offset-width: 0px;
12921245
--tw-ring-offset-color: #fff;
12931246
--tw-ring-offset-shadow: 0 0 #0000;
1294-
--tw-outline-style: solid;
12951247
--tw-blur: initial;
12961248
--tw-brightness: initial;
12971249
--tw-contrast: initial;

tsunami/frontend/src/input.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import * as React from "react";
5+
6+
type Props = {
7+
value?: string;
8+
onChange?: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
9+
onInput?: (e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
10+
ttlMs?: number; // default 100
11+
ref?: React.Ref<HTMLInputElement | HTMLTextAreaElement>;
12+
_tagName: "input" | "textarea";
13+
} & Omit<React.InputHTMLAttributes<HTMLInputElement> & React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onInput">;
14+
15+
/**
16+
* OptimisticInput - A React input component that provides optimistic UI updates for Tsunami's framework.
17+
*
18+
* Problem: In Tsunami's reactive framework, every onChange event is sent to the server, which can cause
19+
* the cursor to jump or typing to feel laggy as the server responds with updates.
20+
*
21+
* Solution: This component applies updates optimistically by maintaining a "shadow" value that shows
22+
* immediately in the UI while waiting for server acknowledgment. If the server responds with the same
23+
* value within the TTL period (default 100ms), the optimistic update is confirmed. If the server
24+
* doesn't respond or responds with a different value, the input reverts to the server value.
25+
*
26+
* Key behaviors:
27+
* - For controlled inputs (value provided): Uses optimistic updates with shadow state
28+
* - For uncontrolled inputs (value undefined): Behaves like a normal React input
29+
* - Skips optimistic logic when disabled or readonly
30+
* - Handles IME composition properly to avoid interfering with multi-byte character input
31+
* - Supports both onChange and onInput event handlers
32+
* - Preserves cursor position through React's natural behavior (no manual cursor management)
33+
*
34+
* Example usage:
35+
* ```tsx
36+
* <OptimisticInput
37+
* value={serverValue}
38+
* onChange={(e) => sendToServer(e.target.value)}
39+
* ttlMs={200}
40+
* />
41+
* ```
42+
*/
43+
function OptimisticInput({ value, onChange, onInput, ttlMs = 100, ref: forwardedRef, _tagName, ...rest }: Props) {
44+
const [shadow, setShadow] = React.useState<string | null>(null);
45+
const timer = React.useRef<number | undefined>(undefined);
46+
47+
const startTTL = React.useCallback(() => {
48+
if (timer.current) clearTimeout(timer.current);
49+
timer.current = window.setTimeout(() => {
50+
// no ack within TTL → revert to server
51+
setShadow(null);
52+
// caret will follow serverValue; optionally restore selRef here if you track a server caret
53+
}, ttlMs);
54+
}, [ttlMs]);
55+
56+
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
57+
// Skip validation during IME composition
58+
// (works in modern browsers/React via nativeEvent)
59+
// @ts-expect-error React typing doesn't surface this directly
60+
if (e.nativeEvent?.isComposing) return;
61+
62+
// If uncontrolled (value is undefined), skip optimistic logic
63+
if (value === undefined) {
64+
onChange?.(e);
65+
onInput?.(e);
66+
return;
67+
}
68+
69+
// Skip optimistic logic if readonly or disabled
70+
if (rest.disabled || rest.readOnly) {
71+
onChange?.(e);
72+
onInput?.(e);
73+
return;
74+
}
75+
76+
const v = e.currentTarget.value;
77+
setShadow(v); // optimistic echo
78+
startTTL(); // wait for ack
79+
onChange?.(e);
80+
onInput?.(e);
81+
};
82+
83+
// Ack: backend caught up → drop shadow (and stop the TTL)
84+
React.useLayoutEffect(() => {
85+
if (shadow !== null && shadow === value) {
86+
setShadow(null);
87+
if (timer.current) clearTimeout(timer.current);
88+
}
89+
}, [value, shadow]);
90+
91+
React.useEffect(
92+
() => () => {
93+
if (timer.current) clearTimeout(timer.current);
94+
},
95+
[]
96+
);
97+
98+
const realValue = value === undefined ? undefined : (shadow ?? value ?? "");
99+
100+
if (_tagName === "textarea") {
101+
return <textarea ref={forwardedRef as React.Ref<HTMLTextAreaElement>} value={realValue} onChange={handleChange} {...rest} />;
102+
}
103+
104+
return <input ref={forwardedRef as React.Ref<HTMLInputElement>} value={realValue} onChange={handleChange} {...rest} />;
105+
}
106+
107+
export default OptimisticInput;

tsunami/frontend/src/vdom.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getTextChildren } from "@/model/model-utils";
1111
import type { TsunamiModel } from "@/model/tsunami-model";
1212
import { RechartsTag } from "@/recharts/recharts";
1313
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
14+
import OptimisticInput from "./input";
1415

1516
const TextTag = "#text";
1617
const FragmentTag = "#fragment";
@@ -278,6 +279,17 @@ function VDomTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {
278279
if (elem.tag == FragmentTag) {
279280
return childrenComps;
280281
}
282+
283+
// Use OptimisticInput for input and textarea elements
284+
if (elem.tag === "input" || elem.tag === "textarea") {
285+
props.key = "e-" + elem.waveid;
286+
const optimisticProps = {
287+
...props,
288+
_tagName: elem.tag as "input" | "textarea"
289+
};
290+
return React.createElement(OptimisticInput, optimisticProps, childrenComps);
291+
}
292+
281293
props.key = "e-" + elem.waveid;
282294
return React.createElement(elem.tag, props, childrenComps);
283295
}

0 commit comments

Comments
 (0)