diff --git a/typescript/packages/jumble/src/iframe-ctx.ts b/typescript/packages/jumble/src/iframe-ctx.ts index 0449d0b6c..830ee17cd 100644 --- a/typescript/packages/jumble/src/iframe-ctx.ts +++ b/typescript/packages/jumble/src/iframe-ctx.ts @@ -17,6 +17,76 @@ const serializeProxyObjects = (proxy: any) => { return proxy == undefined ? undefined : JSON.parse(JSON.stringify(proxy)); }; +// Type for tracking write operations per context+key +type TimeoutId = ReturnType; +type WriteTracking = { + pendingTimeout: TimeoutId | null; + pendingCallback: (() => void) | null; // Store the callback to execute when timeout fires + writeCount: number; + lastResetTime: number; +}; + +// Map to store write tracking by context and key +const writeTrackers = new Map>(); + +// Configuration +const MAX_IMMEDIATE_WRITES_PER_SECOND = 20; // Allow 20 immediate writes per second +const THROTTLED_WRITE_INTERVAL_MS = 100; // 0.1s interval after threshold + +// Throttle function that handles write rate limiting +function throttle(context: any, key: string, callback: () => void): void { + // Get or create context map for this specific context + if (!writeTrackers.has(context)) { + writeTrackers.set(context, new Map()); + } + const contextMap = writeTrackers.get(context)!; + + // Get or initialize tracking info for this key + if (!contextMap.has(key)) { + contextMap.set(key, { + pendingTimeout: null, + pendingCallback: null, + writeCount: 0, + lastResetTime: Date.now(), + }); + } + + const tracking = contextMap.get(key)!; + const now = Date.now(); + + // Reset counter if a second has passed + if (now - tracking.lastResetTime > 1000) { + tracking.writeCount = 0; + tracking.lastResetTime = now; + if (tracking.pendingTimeout) { + clearTimeout(tracking.pendingTimeout); + tracking.pendingTimeout = null; + } + } + + // If we're under the threshold, process immediately + if (tracking.writeCount < MAX_IMMEDIATE_WRITES_PER_SECOND) { + tracking.writeCount++; + // Execute callback immediately + callback(); + } else { + // Update the callback to be executed when the timeout fires + tracking.pendingCallback = callback; + + // Only set a new timeout if there isn't one already + if (!tracking.pendingTimeout) { + tracking.pendingTimeout = setTimeout(() => { + // Execute the latest callback + tracking.pendingCallback?.(); + + // Clear the timeout reference + tracking.pendingTimeout = null; + tracking.pendingCallback = null; + }, THROTTLED_WRITE_INTERVAL_MS); + } + } +} + export const setupIframe = () => setIframeContextHandler({ read(context: any, key: string): any { @@ -25,23 +95,30 @@ export const setupIframe = () => return serialized; }, write(context: any, key: string, value: any) { - if (isCell(context)) { - context.key(key).setRaw(value); - } else { - context[key] = value; - } + throttle(context, key, () => { + if (isCell(context)) { + context.key(key).setRaw(value); + } else { + context[key] = value; + } + }); }, subscribe( context: any, key: string, callback: (key: string, value: any) => void, ): any { + let previousValue: any; + const action: Action = (log: ReactivityLog) => { const data = isCell(context) ? context.withLog(log).key(key).get() : context?.[key]; const serialized = serializeProxyObjects(data); - callback(key, serialized); + if (serialized !== previousValue) { + previousValue = serialized; + callback(key, serialized); + } }; addAction(action);