From cb7e3da0cdb221d03c65cbdbd249f317295e4437 Mon Sep 17 00:00:00 2001 From: bitkojine <74838686+bitkojine@users.noreply.github.com> Date: Wed, 18 Feb 2026 06:07:46 +0200 Subject: [PATCH 1/2] Make BrowserRunner generic over TMsg for type-safe consumers --- .../platform-browser/src/runners/index.ts | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/platform-browser/src/runners/index.ts b/packages/platform-browser/src/runners/index.ts index 74a9f06..66683c3 100644 --- a/packages/platform-browser/src/runners/index.ts +++ b/packages/platform-browser/src/runners/index.ts @@ -15,7 +15,7 @@ export interface BrowserRunnerOptions { createWorker?: (url: string | URL, options?: WorkerOptions) => Worker; createAbortController?: () => AbortController; } -export class BrowserRunner { +export class BrowserRunner { private controllers = new Map(); private readonly fetch: typeof fetch; private readonly createWorker: ( @@ -29,8 +29,8 @@ export class BrowserRunner { private workerQueue = new Map< string, { - effect: WorkerEffect; - dispatch: (msg: Msg) => void; + effect: WorkerEffect; + dispatch: (msg: TMsg) => void; }[] >(); constructor( @@ -46,7 +46,7 @@ export class BrowserRunner { options.createAbortController ?? (() => new AbortController()); this.maxWorkersPerUrl = options.maxWorkersPerUrl ?? 4; } - public run(effect: CoreEffect, dispatch: (msg: Msg) => void): void { + public run(effect: CoreEffect, dispatch: (msg: TMsg) => void): void { try { switch (effect.kind) { case "fetch": @@ -73,14 +73,17 @@ export class BrowserRunner { } } private runWrapper( - effect: WrappedEffect, - dispatch: (msg: Msg) => void, + effect: WrappedEffect, + dispatch: (msg: TMsg) => void, ): void { - this.run(effect.original, (msg: unknown) => { - dispatch(effect.wrap(msg)); + this.run(effect.original as CoreEffect, (msg: TMsg) => { + dispatch(effect.wrap(msg) as TMsg); }); } - private runFetch(effect: FetchEffect, dispatch: (msg: Msg) => void): void { + private runFetch( + effect: FetchEffect, + dispatch: (msg: TMsg) => void, + ): void { const { url, method = "GET", @@ -135,7 +138,10 @@ export class BrowserRunner { } }); } - private runTimer(effect: TimerEffect, dispatch: (msg: Msg) => void): void { + private runTimer( + effect: TimerEffect, + dispatch: (msg: TMsg) => void, + ): void { setTimeout(() => { dispatch(effect.onTimeout()); }, effect.timeoutMs); @@ -148,14 +154,17 @@ export class BrowserRunner { } } private runAnimationFrame( - effect: AnimationFrameEffect, - dispatch: (msg: Msg) => void, + effect: AnimationFrameEffect, + dispatch: (msg: TMsg) => void, ): void { requestAnimationFrame((time) => { dispatch(effect.onFrame(time)); }); } - private runWorker(effect: WorkerEffect, dispatch: (msg: Msg) => void): void { + private runWorker( + effect: WorkerEffect, + dispatch: (msg: TMsg) => void, + ): void { const { scriptUrl } = effect; let pool = this.workersByUrl.get(scriptUrl); if (!pool) { @@ -180,8 +189,8 @@ export class BrowserRunner { } private executeOnWorker( worker: Worker, - effect: WorkerEffect, - dispatch: (msg: Msg) => void, + effect: WorkerEffect, + dispatch: (msg: TMsg) => void, ): void { this.busyWorkers.add(worker); let timeoutId: ReturnType | undefined; @@ -239,13 +248,13 @@ export class BrowserRunner { } private activeSubscriptions = new Map void }>(); public startSubscription( - sub: Subscription, - dispatch: (msg: Msg) => void, + sub: Subscription, + dispatch: (msg: TMsg) => void, ): void { this.stopSubscription(sub.key); switch (sub.kind) { case "timer": { - const timerSub = sub as TimerSubscription; + const timerSub = sub as TimerSubscription; const id = setInterval(() => { dispatch(timerSub.onTick()); }, timerSub.intervalMs); @@ -255,7 +264,7 @@ export class BrowserRunner { break; } case "animationFrame": { - const animSub = sub as AnimationFrameSubscription; + const animSub = sub as AnimationFrameSubscription; let active = true; const loop = (time: number) => { if (!active) return; From dd0d056cedc291f35e4e7706a97e8fabf3b6802f Mon Sep 17 00:00:00 2001 From: bitkojine <74838686+bitkojine@users.noreply.github.com> Date: Wed, 18 Feb 2026 06:40:29 +0200 Subject: [PATCH 2/2] docs: add conditional subscription pausing idea and immutable bulk ops findings --- docs/notes/ideas.md | 12 ++++ docs/notes/immutable-bulk-operations.md | 80 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 docs/notes/immutable-bulk-operations.md diff --git a/docs/notes/ideas.md b/docs/notes/ideas.md index 89eec39..6618289 100644 --- a/docs/notes/ideas.md +++ b/docs/notes/ideas.md @@ -63,3 +63,15 @@ Adding subscriptions to `@causaloop/core` would: 2. Align with Elm's architecture more faithfully 3. Make replay/restore safe by default — the framework's core value proposition 4. Position Causaloop as solving a problem that even Elm sidesteps rather than solves + +### Conditional subscription pausing + +The `subscriptions` function receives the current model, which enables conditionally starting or stopping subscriptions based on state. No consumer currently exercises this — causal-factory's `subscriptions` function ignores the model parameter entirely (always returns the same animationFrame subscription). + +Potential use cases: + +- Return an empty array when `model.isPaused === true` to pause the game loop +- Start a countdown timer subscription only during a specific game phase +- Switch from animationFrame to a slower timer when entity count exceeds a threshold + +The dispatcher already handles subscription diffing (`diffSubscriptions`) — adding/removing subscriptions between commits is fully supported. This just needs a real consumer to exercise it. diff --git a/docs/notes/immutable-bulk-operations.md b/docs/notes/immutable-bulk-operations.md new file mode 100644 index 0000000..8589209 --- /dev/null +++ b/docs/notes/immutable-bulk-operations.md @@ -0,0 +1,80 @@ +# Immutable Bulk Operations: The O(n²) Spread Trap + +## Problem + +Dispatching N individual messages that each modify the same N-sized collection is always O(n²) with immutable state — regardless of data structure. + +## Discovery + +In [causal-factory](https://github.com/bitkojine/causal-factory), the "Event Storm" button resets all bots to idle. The naive approach dispatched one `reset_bot` message per bot: + +``` +window.triggerMarketCrash = () => { + for (const bot of Object.values(latestSnapshot.bots)) { + dispatcher.dispatch({ kind: 'reset_bot', botId: bot.id }); + } +}; +``` + +### Array-based bots (`Bot[]`) + +Each `reset_bot` did `.map()` over the entire array to find and replace one bot: + +``` +bots: model.bots.map(b => b.id === msg.botId ? { ...b, state: idle } : b) +``` + +With 30k bots: 30k messages × 30k iterations = **~900M operations**. Browser froze. + +### Record-based bots (`Record`) + +Converted to Record for O(1) lookup: + +``` +bots: { ...model.bots, [msg.botId]: { ...model.bots[msg.botId], state: idle } } +``` + +Lookup is now O(1), but `...model.bots` still **copies all 30k entries** to create the new immutable object. With 30k messages: 30k × 30k copies = **~900M copy operations**. Browser still froze. + +### Why this is fundamental + +Each message in the dispatcher queue produces a new model. The next message operates on that new model. There is no way to "batch" mutations across messages without breaking the sequential guarantee of MVU. + +``` +Message 1: spread 30k entries → new model +Message 2: spread 30k entries from new model → new model +... +Message 30k: spread 30k entries → new model +``` + +This is inherent to immutable state management. The bottleneck isn't the lookup — it's the spread. + +## Solution: Bulk messages + +The correct pattern is a single message that handles the entire operation in one pass: + +``` +case 'market_crash': { + const resetBots: Record = {}; + for (const id in model.bots) { + resetBots[id] = { ...model.bots[id], state: { kind: 'idle' } }; + } + return { model: { ...model, bots: resetBots }, effects: [] }; +} +``` + +One spread of the collection, one pass over all entities = O(n). With 41,700 bots this runs smoothly at ~45 FPS. + +## Key takeaway + +**Individual messages are for individual operations.** When you need to touch every entity, use a bulk message. The dispatcher's sequential guarantee is a strength for correctness, but it means each message pays the full cost of immutable state creation. Design messages accordingly. + +## When individual entity messages ARE appropriate + +The `reset_bot` message type still exists and works well for targeted operations — resetting a single bot in response to a user click, for example. The O(1) Record lookup makes this fast. The problem only arises when dispatching thousands of them in a tight loop. + +## Related + +- `Record` is still the better data structure (consistent with `machines`, O(1) individual access) +- Persistent data structures (HAMTs) could theoretically solve this with O(log n) updates, but add complexity +- Elm has the same constraint — bulk operations are the standard pattern there too