@@ -8,6 +8,14 @@ import {
88 type TargetedEvent ,
99} from "preact" ;
1010import { useContext , useEffect , useRef , useState } from "preact/hooks" ;
11+ import {
12+ HANDLER_DEBOUNCE ,
13+ HANDLER_MARKER ,
14+ HANDLER_THROTTLE ,
15+ getInitialDebounce ,
16+ isValidDebounce ,
17+ type TaggedEventHandler ,
18+ } from "./handler" ;
1119import type {
1220 ImportSourceBinding ,
1321 ReactPyComponent ,
@@ -18,12 +26,25 @@ import type { ReactPyClient } from "./client";
1826
1927const ClientContext = createContext < ReactPyClient > ( null as any ) ;
2028
21- const DEFAULT_INPUT_DEBOUNCE = 200 ;
22-
23- type ReactPyInputHandler = ( ( event : TargetedEvent < any > ) => void ) & {
24- debounce ?: number ;
25- isHandler ?: boolean ;
26- } ;
29+ // Built-in default debounce (ms) used by user-input elements (``<input>``,
30+ // ``<select>``, ``<textarea>``) when no per-handler ``debounce`` is set.
31+ // Non-input elements default to 0 ms (no debounce) — set ``debounce`` or
32+ // ``throttle`` explicitly on the handler if you need either behavior.
33+ const DEFAULT_INPUT_DEBOUNCE_MS = 200 ;
34+ const DEFAULT_NON_INPUT_DEBOUNCE_MS = 0 ;
35+
36+ const USER_INPUT_TAGS = new Set ( [ "input" , "select" , "textarea" ] ) ;
37+
38+ /**
39+ * Return the built-in default debounce (ms) for an element type.
40+ * Inputs default to 200 ms to preserve text-input coherency against
41+ * server-driven ``value`` updates; all other elements default to 0 ms.
42+ */
43+ export function getDefaultDebounceMs ( tagName : string ) : number {
44+ return USER_INPUT_TAGS . has ( tagName )
45+ ? DEFAULT_INPUT_DEBOUNCE_MS
46+ : DEFAULT_NON_INPUT_DEBOUNCE_MS ;
47+ }
2748
2849type UserInputTarget =
2950 | HTMLInputElement
@@ -49,6 +70,64 @@ function trackUserInput(
4970 lastInputDebounce . current = debounce ;
5071}
5172
73+ /**
74+ * Wrap ``handler`` so its outgoing call is throttled to at most once per
75+ * ``intervalMs`` milliseconds. Subsequent calls inside the window are
76+ * collapsed and the trailing call (with the most recent arguments) fires
77+ * once the window expires.
78+ *
79+ * Returns the original handler unchanged when ``intervalMs`` is missing or
80+ * invalid.
81+ */
82+ function throttleHandler (
83+ handler : TaggedEventHandler ,
84+ intervalMs : number ,
85+ ) : TaggedEventHandler {
86+ if ( ! isValidDebounce ( intervalMs ) ) {
87+ return handler ;
88+ }
89+ let pendingArgs : any [ ] | null = null ;
90+ let timer : number | null = null ;
91+ let lastFireTime = 0 ;
92+
93+ const wrapped = function ( ...args : any [ ] ) {
94+ const now = Date . now ( ) ;
95+ const elapsed = now - lastFireTime ;
96+ if ( elapsed >= intervalMs ) {
97+ // Leading edge: fire immediately, then start a cooldown.
98+ lastFireTime = now ;
99+ ( handler as ( ...a : any [ ] ) => void ) ( ...args ) ;
100+ return ;
101+ }
102+ // Trailing edge: remember the latest args and schedule a fire at the
103+ // end of the cooldown so no event is silently dropped.
104+ pendingArgs = args ;
105+ if ( timer === null ) {
106+ timer = window . setTimeout (
107+ ( ) => {
108+ timer = null ;
109+ if ( pendingArgs !== null ) {
110+ const callArgs = pendingArgs ;
111+ pendingArgs = null ;
112+ lastFireTime = Date . now ( ) ;
113+ ( handler as ( ...a : any [ ] ) => void ) ( ...callArgs ) ;
114+ }
115+ } ,
116+ Math . max ( 0 , intervalMs - elapsed ) ,
117+ ) ;
118+ }
119+ } as TaggedEventHandler ;
120+
121+ // Preserve the tag markers so downstream code can still introspect the
122+ // wrapped function.
123+ wrapped [ HANDLER_MARKER ] = true ;
124+ const debounce = handler [ HANDLER_DEBOUNCE ] ;
125+ if ( typeof debounce === "number" ) {
126+ wrapped [ HANDLER_DEBOUNCE ] = debounce ;
127+ }
128+ return wrapped ;
129+ }
130+
52131export function Layout ( props : { client : ReactPyClient } ) : JSX . Element {
53132 const currentModel : ReactPyVdom = useState ( { tagName : "" } ) [ 0 ] ;
54133 const forceUpdate = useForceUpdate ( ) ;
@@ -97,12 +176,29 @@ export function Element({ model }: { model: ReactPyVdom }): JSX.Element | null {
97176
98177function StandardElement ( { model } : { model : ReactPyVdom } ) {
99178 const client = useContext ( ClientContext ) ;
179+ const attrs = createAttributes ( model , client ) ;
180+ // Apply the throttle wrapper to tagged handlers. ``debounce`` (server-→
181+ // client value reconciliation) only applies to user-input elements, so
182+ // it's intentionally ignored here; ``throttle`` works everywhere.
183+ for ( const [ name , prop ] of Object . entries ( attrs ) ) {
184+ if ( typeof prop !== "function" ) {
185+ continue ;
186+ }
187+ const handler = prop as TaggedEventHandler ;
188+ if ( ! handler [ HANDLER_MARKER ] ) {
189+ continue ;
190+ }
191+ const throttle = handler [ HANDLER_THROTTLE ] ;
192+ if ( isValidDebounce ( throttle ) ) {
193+ attrs [ name ] = throttleHandler ( handler , throttle as number ) ;
194+ }
195+ }
100196 // Use createElement here to avoid warning about variable numbers of children not
101197 // having keys. Warning about this must now be the responsibility of the client
102198 // providing the models instead of the client rendering them.
103199 return createElement (
104200 model . tagName === "" ? Fragment : model . tagName ,
105- createAttributes ( model , client ) ,
201+ attrs ,
106202 ...createChildren ( model , ( child ) => {
107203 return < Element model = { child } key = { child . attributes ?. key } /> ;
108204 } ) ,
@@ -115,7 +211,13 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
115211 const [ value , setValue ] = useState ( props . value ) ;
116212 const lastUserValue = useRef ( props . value ) ;
117213 const lastChangeTime = useRef ( 0 ) ;
118- const lastInputDebounce = useRef ( DEFAULT_INPUT_DEBOUNCE ) ;
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+ ) ;
119221 const reconcileTimeout = useRef < number | null > ( null ) ;
120222
121223 // honor changes to value from the client via props
@@ -154,23 +256,30 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
154256 continue ;
155257 }
156258
157- const givenHandler = prop as ReactPyInputHandler ;
158- if ( ! givenHandler . isHandler ) {
259+ const givenHandler = prop as TaggedEventHandler ;
260+ if ( ! givenHandler [ HANDLER_MARKER ] ) {
159261 continue ;
160262 }
161263
264+ const handlerDebounce = givenHandler [ HANDLER_DEBOUNCE ] ;
265+ const effectiveDebounce = isValidDebounce ( handlerDebounce )
266+ ? handlerDebounce
267+ : getDefaultDebounceMs ( model . tagName ) ;
268+
269+ const throttled = isValidDebounce ( givenHandler [ HANDLER_THROTTLE ] )
270+ ? throttleHandler ( givenHandler , givenHandler [ HANDLER_THROTTLE ] as number )
271+ : givenHandler ;
272+
162273 props [ name ] = ( event : TargetedEvent < any > ) => {
163274 trackUserInput (
164275 event ,
165276 setValue ,
166277 lastUserValue ,
167278 lastChangeTime ,
168279 lastInputDebounce ,
169- typeof givenHandler . debounce === "number"
170- ? givenHandler . debounce
171- : DEFAULT_INPUT_DEBOUNCE ,
280+ effectiveDebounce ,
172281 ) ;
173- givenHandler ( event ) ;
282+ throttled ( event ) ;
174283 } ;
175284 }
176285
0 commit comments