foldkit 0.129.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/THIRD-PARTY-NOTICES.md +30 -0
  2. package/dist/brand/brand.d.ts +25 -0
  3. package/dist/brand/brand.d.ts.map +1 -0
  4. package/dist/brand/brand.js +50 -0
  5. package/dist/brand/index.d.ts +2 -0
  6. package/dist/brand/index.d.ts.map +1 -0
  7. package/dist/brand/index.js +1 -0
  8. package/dist/brand/public.d.ts +2 -0
  9. package/dist/brand/public.d.ts.map +1 -0
  10. package/dist/brand/public.js +1 -0
  11. package/dist/canvas/view.d.ts.map +1 -1
  12. package/dist/canvas/view.js +1 -1
  13. package/dist/devTools/protocol.d.ts +1 -1
  14. package/dist/devTools/protocol.d.ts.map +1 -1
  15. package/dist/devTools/protocol.js +1 -1
  16. package/dist/devTools/store.js +2 -2
  17. package/dist/devTools/webSocketBridge.d.ts +1 -1
  18. package/dist/devTools/webSocketBridge.js +1 -1
  19. package/dist/html/index.d.ts +18 -18
  20. package/dist/html/index.d.ts.map +1 -1
  21. package/dist/html/index.js +530 -460
  22. package/dist/html/lazy.d.ts +1 -1
  23. package/dist/html/lazy.d.ts.map +1 -1
  24. package/dist/html/lazy.js +11 -2
  25. package/dist/propsModule.d.ts +1 -1
  26. package/dist/propsModule.d.ts.map +1 -1
  27. package/dist/propsModule.js +6 -1
  28. package/dist/runtime/runtime.d.ts.map +1 -1
  29. package/dist/runtime/runtime.js +468 -278
  30. package/dist/snabbdom/attributes.d.ts +4 -0
  31. package/dist/snabbdom/attributes.d.ts.map +1 -0
  32. package/dist/snabbdom/attributes.js +63 -0
  33. package/dist/snabbdom/class.d.ts +4 -0
  34. package/dist/snabbdom/class.d.ts.map +1 -0
  35. package/dist/snabbdom/class.js +31 -0
  36. package/dist/snabbdom/dataset.d.ts +4 -0
  37. package/dist/snabbdom/dataset.d.ts.map +1 -0
  38. package/dist/snabbdom/dataset.js +42 -0
  39. package/dist/snabbdom/eventlisteners.d.ts +11 -0
  40. package/dist/snabbdom/eventlisteners.d.ts.map +1 -0
  41. package/dist/snabbdom/eventlisteners.js +85 -0
  42. package/dist/snabbdom/h.d.ts +11 -0
  43. package/dist/snabbdom/h.d.ts.map +1 -0
  44. package/dist/snabbdom/h.js +61 -0
  45. package/dist/snabbdom/hooks.d.ts +24 -0
  46. package/dist/snabbdom/hooks.d.ts.map +1 -0
  47. package/dist/snabbdom/hooks.js +1 -0
  48. package/dist/snabbdom/htmldomapi.d.ts +34 -0
  49. package/dist/snabbdom/htmldomapi.d.ts.map +1 -0
  50. package/dist/snabbdom/htmldomapi.js +111 -0
  51. package/dist/snabbdom/index.d.ts +15 -0
  52. package/dist/snabbdom/index.d.ts.map +1 -0
  53. package/dist/snabbdom/index.js +9 -0
  54. package/dist/snabbdom/init.d.ts +14 -0
  55. package/dist/snabbdom/init.d.ts.map +1 -0
  56. package/dist/snabbdom/init.js +486 -0
  57. package/dist/snabbdom/is.d.ts +3 -0
  58. package/dist/snabbdom/is.d.ts.map +1 -0
  59. package/dist/snabbdom/is.js +7 -0
  60. package/dist/snabbdom/module.d.ts +11 -0
  61. package/dist/snabbdom/module.d.ts.map +1 -0
  62. package/dist/snabbdom/module.js +1 -0
  63. package/dist/snabbdom/props.d.ts +2 -0
  64. package/dist/snabbdom/props.d.ts.map +1 -0
  65. package/dist/snabbdom/props.js +1 -0
  66. package/dist/snabbdom/style.d.ts +9 -0
  67. package/dist/snabbdom/style.d.ts.map +1 -0
  68. package/dist/snabbdom/style.js +123 -0
  69. package/dist/snabbdom/tovnode.d.ts +4 -0
  70. package/dist/snabbdom/tovnode.d.ts.map +1 -0
  71. package/dist/snabbdom/tovnode.js +69 -0
  72. package/dist/snabbdom/vnode.d.ts +46 -0
  73. package/dist/snabbdom/vnode.d.ts.map +1 -0
  74. package/dist/snabbdom/vnode.js +13 -0
  75. package/dist/vdom.d.ts +2 -2
  76. package/dist/vdom.d.ts.map +1 -1
  77. package/dist/vdom.js +1 -1
  78. package/package.json +7 -5
  79. package/dist/runtime/messagePriority.d.ts +0 -17
  80. package/dist/runtime/messagePriority.d.ts.map +0 -1
  81. package/dist/runtime/messagePriority.js +0 -33
  82. package/dist/runtime/renderLoop.d.ts +0 -27
  83. package/dist/runtime/renderLoop.d.ts.map +0 -1
  84. package/dist/runtime/renderLoop.js +0 -25
@@ -1,4 +1,4 @@
1
- import { Array, Cause, Context, Duration, Effect, Exit, Fiber, Function, Layer, Match, Option, Predicate, PubSub, Queue, Record, Ref, Scheduler, Schema, Stream, SubscriptionRef, pipe, } from 'effect';
1
+ import { Array, Cause, Context, Duration, Effect, Exit, Fiber, Function, Layer, Match, Option, Predicate, PubSub, Record, Ref, Scheduler, Schema, Stream, SubscriptionRef, pipe, } from 'effect';
2
2
  import { BrowserRuntime } from '@effect/platform-browser';
3
3
  import { __CurrentRegistry as __CurrentInterruptRegistry, __makeRegistry as __makeInterruptRegistry, } from '../command/interruptible/index.js';
4
4
  import { createDevToolsStore, } from '../devTools/store.js';
@@ -13,9 +13,7 @@ import { defaultCrashView, noOpDispatch } from './crashUI.js';
13
13
  import { deepFreeze } from './deepFreeze.js';
14
14
  import { PreserveModelMessage, RequestModelMessage, RestoreModelMessage, } from './hmrProtocol.js';
15
15
  import { preserveScrollPosition, restorePreservedScrollPosition, } from './hmrScroll.js';
16
- import { orderByPriority } from './messagePriority.js';
17
16
  import { makePreserveScheduler } from './preserveScheduler.js';
18
- import { makeRenderLoop } from './renderLoop.js';
19
17
  const toCommandRecord = (command) => command.args !== undefined
20
18
  ? { name: command.name, args: command.args }
21
19
  : { name: command.name };
@@ -339,57 +337,17 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
339
337
  validatePorts(ports);
340
338
  }
341
339
  const runtimeId = container?.id ?? '';
342
- // NOTE: When the message queue drains a chain of dispatches (e.g. recursive
343
- // Commands, websocket bursts), processing all of them inside one macrotask
344
- // blocks the browser from painting. Yield via MessageChannel once the
345
- // current burst exceeds FRAME_BUDGET_MS so the browser gets a frame.
346
- // setTimeout(0) is clamped to 4ms+; MessageChannel delivers in ~0.5ms.
347
- const FRAME_BUDGET_MS = 5;
348
- // NOTE: render coalescing relies on this firing once per frame. Multiple
349
- // Messages dispatched between frames all flag the renderLoop dirty; the
350
- // next rAF tick reads the latest model and renders once. Without this,
351
- // every Message would call render() inline, and during high-rate streams
352
- // (drag pointermove, websocket bursts) the runtime would paint each
353
- // intermediate frame with the cursor leading the rendered position.
354
- const awaitNextFrame = Effect.callback(resume => {
355
- const handle = requestAnimationFrame(() => resume(Effect.void));
356
- return Effect.sync(() => cancelAnimationFrame(handle));
357
- });
358
340
  const startWith = (maybeConnector, hmrModel) => Effect.scoped(Effect.gen(function* () {
359
341
  if (runtimeId === '') {
360
342
  return yield* Effect.die(new Error('[foldkit] Runtime container must have an `id` for HMR model preservation. ' +
361
343
  'Set `container.id = "app"` (or any unique string) before passing it to makeApplication or makeElement.'));
362
344
  }
363
- // NOTE: every perpetual fiber (render loop, Subscription streams,
364
- // ManagedResource lifecycles) and every Command fiber forks into the
365
- // runtime scope, so interrupting the runtime fiber (what dispose
366
- // does) interrupts them all and runs their finalizers. A detached
367
- // fork would outlive the runtime.
345
+ // NOTE: every perpetual fiber (for example, Subscription streams
346
+ // and ManagedResource lifecycles) and every Command fiber forks
347
+ // into the runtime scope, so interrupting the runtime fiber (what
348
+ // dispose does) interrupts them all and runs their finalizers. A
349
+ // detached fork would outlive the runtime.
368
350
  const runtimeScope = yield* Effect.scope;
369
- // NOTE: one persistent MessageChannel for the runtime lifetime,
370
- // shared by every burst-budget yield. The queue-drain fiber is the
371
- // sole consumer, so a single `pendingYieldResume` slot is sufficient.
372
- const yieldChannel = yield* Effect.acquireRelease(Effect.sync(() => new MessageChannel()), channel => Effect.sync(() => {
373
- channel.port1.close();
374
- channel.port2.close();
375
- }));
376
- let pendingYieldResume = null;
377
- yieldChannel.port2.onmessage = () => {
378
- const resume = pendingYieldResume;
379
- pendingYieldResume = null;
380
- if (resume !== null) {
381
- resume(Effect.void);
382
- }
383
- };
384
- const yieldToBrowser = Effect.callback(resume => {
385
- pendingYieldResume = resume;
386
- yieldChannel.port1.postMessage(null);
387
- return Effect.sync(() => {
388
- if (pendingYieldResume === resume) {
389
- pendingYieldResume = null;
390
- }
391
- });
392
- });
393
351
  // NOTE: `Effect.provide(effect, layer)` builds the Layer into a
394
352
  // scope that closes when the provided effect ends, so providing the
395
353
  // Layer per Command would construct and tear down every resource on
@@ -496,18 +454,64 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
496
454
  }), handler => Effect.sync(() => hot.off('vite:beforeFullReload', handler)));
497
455
  }
498
456
  const schedulePreserveModel = (model) => hot ? preserveScheduler.schedule(model) : Effect.void;
499
- // NOTE: Each enqueued Message carries a priority. Within a single
500
- // takeAll batch the drain loop processes all High before any Normal,
501
- // so user input (view dispatch, navigation, subscription events,
502
- // managed-resource events, external dispatchers) lands ahead of
503
- // chain-derived work (Command results) when they share a frame.
504
- // FIFO order is preserved within a priority class.
505
- const messageQueue = yield* Queue.unbounded();
506
- const enqueueHigh = (message) => Queue.offer(messageQueue, { priority: 'High', message });
507
- const enqueueNormal = (message) => Queue.offer(messageQueue, { priority: 'Normal', message });
508
- const enqueueHighUnsafe = (message) => {
509
- Queue.offerUnsafe(messageQueue, { priority: 'High', message });
457
+ // NOTE: the dispatch hot path is plain JavaScript. A dispatched
458
+ // Message is pushed onto a plain array and drained synchronously on
459
+ // the spot, so update runs on the dispatching stack (for example, a
460
+ // DOM event handler, a Command fiber completing, or a Subscription
461
+ // emit) with no fiber hop in between. The drain guards against
462
+ // re-entrancy: a Message dispatched mid-drain (for example, by an
463
+ // update triggered from a synchronous Command) is queued and picked
464
+ // up by the outer drain loop in arrival order, and a Message
465
+ // dispatched while a render frame's patch is on the stack is
466
+ // buffered until the frame completes.
467
+ let pendingMessages = [];
468
+ let isProcessingMessages = false;
469
+ let isRenderFrameScheduled = false;
470
+ // NOTE: mirrors the old queue's boot behavior: a Message arriving
471
+ // before boot completes (for example, a navigation event during an
472
+ // async dev-mode boot step, or a boot-forked fiber emitting early)
473
+ // is buffered, not processed. Processing against a partially
474
+ // initialized runtime would race the init render, DevTools
475
+ // recording, and Subscription attachment. The flag flips as the
476
+ // last act of boot, which then drains the buffer. enqueueMessage
477
+ // checks it directly, not just the drain: dispatch sources go live
478
+ // mid-boot, before `drainPendingMessages` is initialized, and
479
+ // calling it from a pre-boot dispatch would hit the temporal dead
480
+ // zone.
481
+ let isBootComplete = false;
482
+ // NOTE: mirrors the old queue's post-interrupt behavior: a Message
483
+ // dispatched after the runtime scope closed (for example, an
484
+ // OnUnmount fired by the dispose teardown patch, or a stale DOM
485
+ // handler) is dropped
486
+ // instead of updating a disposed runtime. Set by a finalizer
487
+ // registered at the end of boot, so it runs before
488
+ // earlier-registered teardown (finalizers are LIFO).
489
+ let isRuntimeDisposed = false;
490
+ // NOTE: the differ fires destroy and insert hooks while `patch` is
491
+ // on the stack, and both can dispatch synchronously (for example,
492
+ // an OnUnmount dispatch, or a Mount stream's synchronous first
493
+ // emission). Draining
494
+ // inline would run update, and on a defect the crash renderer,
495
+ // against a DOM the outer patch is still mutating. The frame
496
+ // buffers such dispatches and drains them after it completes.
497
+ let isRenderingFrame = false;
498
+ // NOTE: a crash is terminal. The old runtime's drain fiber died on
499
+ // the first defect, so nothing was processed after a crash; this
500
+ // flag preserves that: the drain stops and later dispatches are
501
+ // dropped, so update, Command forks, and DevTools recording all
502
+ // stop with the crash view on screen.
503
+ let isCrashed = false;
504
+ const enqueueMessage = (message) => {
505
+ if (isRuntimeDisposed || isCrashed) {
506
+ return;
507
+ }
508
+ pendingMessages.push(message);
509
+ if (!isBootComplete || isRenderingFrame) {
510
+ return;
511
+ }
512
+ drainPendingMessages();
510
513
  };
514
+ const enqueueMessageEffect = (message) => Effect.sync(() => enqueueMessage(message));
511
515
  const currentUrl = Option.fromNullishOr(routingConfig).pipe(Option.flatMap(() => urlFromString(window.location.href)));
512
516
  const [initModelRaw, initCommands] = Predicate.isNotUndefined(hmrModel)
513
517
  ? Exit.match(decodeHmrModel(hmrModel), {
@@ -521,10 +525,15 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
521
525
  yield* Effect.addFinalizer(() => Effect.sync(() => duplicateIdScanner?.cancel()));
522
526
  }
523
527
  if (routingConfig) {
524
- yield* Effect.acquireRelease(Effect.sync(() => addNavigationEventListeners(enqueueHighUnsafe, routingConfig)), removeNavigationEventListeners => Effect.sync(() => removeNavigationEventListeners()));
528
+ yield* Effect.acquireRelease(Effect.sync(() => addNavigationEventListeners(enqueueMessage, routingConfig)), removeNavigationEventListeners => Effect.sync(() => removeNavigationEventListeners()));
525
529
  }
526
- const modelRef = yield* Ref.make(initModel);
527
- const maybeCurrentVNodeRef = yield* Ref.make(Option.none());
530
+ // NOTE: the model and the current vnode are plain closure state.
531
+ // The hot path reads and writes them directly; the cold paths that
532
+ // run inside Effects (crash rendering, the dispose finalizer, the
533
+ // replay render) read the same variables synchronously, so no Ref
534
+ // is needed.
535
+ let liveModel = initModel;
536
+ const vnodeSlot = { maybeCurrentVNode: Option.none() };
528
537
  // NOTE: registered before any perpetual fiber is forked so it runs
529
538
  // after they are interrupted (scope finalizers are LIFO). Patching to
530
539
  // an empty tree fires snabbdom destroy hooks, which is what releases
@@ -538,7 +547,7 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
538
547
  if (!Exit.hasInterrupts(exit)) {
539
548
  return;
540
549
  }
541
- const maybeCurrentVNode = yield* Ref.get(maybeCurrentVNodeRef);
550
+ const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
542
551
  yield* Option.match(maybeCurrentVNode, {
543
552
  onNone: () => Effect.void,
544
553
  onSome: currentVNode => Effect.sync(() => {
@@ -550,66 +559,67 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
550
559
  }),
551
560
  });
552
561
  }));
553
- const isCrashedRef = yield* Ref.make(false);
554
- // NOTE: shared by every fiber's crash path: init render, render
555
- // loop, message drain, and the Command and Subscription forks (a
556
- // Command's Effect and a Subscription's Stream are typed with a
557
- // `never` error channel, so a cause escaping one can only be a
558
- // `resources` Layer build failure or an escaped defect, both
559
- // unrecoverable). Each fiber catches its own cause so
560
- // a failure surfaces as the crash view instead of dying silently
561
- // and leaving the DOM frozen at the last successful render. The
562
- // first crash wins: concurrent Command fibers can fail on the same
563
- // broken Layer, and only one should report and render.
564
- const crashWith = (cause, maybeMessage) => Effect.gen(function* () {
565
- const wasCrashed = yield* Ref.getAndSet(isCrashedRef, true);
566
- if (wasCrashed) {
562
+ // NOTE: shared by every crash path: the init render, the plain
563
+ // message drain and render frame (which reach it through
564
+ // `Effect.runFork` from their catch blocks), and the Command and
565
+ // Subscription fibers (a Command's Effect and a Subscription's
566
+ // Stream are typed with a `never` error channel, so a cause
567
+ // escaping one can only be a `resources` Layer build failure or an
568
+ // escaped defect, both unrecoverable). Each path catches its own
569
+ // cause so a failure surfaces as the crash view instead of dying
570
+ // silently and leaving the DOM frozen at the last successful
571
+ // render. The first crash wins: concurrent Command fibers can fail
572
+ // on the same broken Layer, and only one should report and render.
573
+ const crashWith = (cause, maybeMessage) => Effect.sync(() => {
574
+ if (isCrashed) {
567
575
  return;
568
576
  }
569
- const model = yield* Ref.get(modelRef);
577
+ isCrashed = true;
578
+ const model = liveModel;
570
579
  const squashed = Cause.squash(cause);
571
580
  const error = squashed instanceof Error ? squashed : new Error(String(squashed));
572
- renderCrashView({ error, model, message: maybeMessage }, crash, container, maybeCurrentVNodeRef, manageDocument);
581
+ renderCrashView({ error, model, message: maybeMessage }, crash, container, vnodeSlot, manageDocument);
573
582
  });
574
- yield* Effect.forEach(
575
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
576
- initCommands, command => Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
577
- attributes: command.args ?? {},
578
- }), provideAllResources, Effect.flatMap(enqueueNormal), Effect.catchCause(cause => crashWith(cause, Option.none())))));
579
- // NOTE: queue-drain-fiber-local state. Kept as plain closure
580
- // variables instead of `Ref`s because nothing else reads or writes
581
- // them concurrently, and JS's single-threaded model already orders
582
- // writes against subsequent reads. `currentMessage` is read by the
583
- // crash handler, which runs inside the same `forever` fiber via
584
- // `Effect.catchCause`.
583
+ // NOTE: drain-local state. Kept as plain closure variables instead
584
+ // of `Ref`s because nothing else reads or writes them concurrently,
585
+ // and JS's single-threaded model already orders writes against
586
+ // subsequent reads. `currentMessage` is read by the crash handler.
585
587
  let currentMessage = Option.none();
586
- let burstStartedAt = 0;
588
+ let maybeLastDirtyMessage = Option.none();
587
589
  // NOTE: the DevTools store is installed at most once during boot and
588
590
  // never replaced. Caching it in a closure variable avoids a
589
- // `Ref.get` on every message and on every render-loop tick (the
590
- // store powers `isPausedEffect`).
591
- let maybeDevToolsStore = Option.none();
591
+ // `Ref.get` on every message and on every render frame (the
592
+ // store powers the pause check). Plain `null` rather than `Option`:
593
+ // the hot path only ever presence-checks it, and the check should
594
+ // stay a bare comparison.
595
+ let devToolsStore = null;
592
596
  const dispatchSync = (message) => {
593
597
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
594
- enqueueHighUnsafe(message);
598
+ enqueueMessage(message);
595
599
  };
596
600
  const dispatchAsync = (message) =>
597
601
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
598
- enqueueHigh(message);
602
+ enqueueMessageEffect(message);
599
603
  const dispatch = { dispatchAsync, dispatchSync };
600
- const isRenderPendingRef = yield* SubscriptionRef.make(false);
601
- const maybeLastDirtyMessageRef = yield* Ref.make(Option.none());
602
- const isPausedEffect = Effect.suspend(() => Option.match(maybeDevToolsStore, {
603
- onNone: () => Effect.succeed(false),
604
- onSome: ({ stateRef }) => SubscriptionRef.get(stateRef).pipe(Effect.map(({ isPaused }) => isPaused)),
605
- }));
604
+ const isPausedNow = () => devToolsStore !== null &&
605
+ SubscriptionRef.getUnsafe(devToolsStore.stateRef).isPaused;
606
+ // NOTE: recording is gated on the DevTools store because the store
607
+ // is the only consumer. Without the gate every Mount start and end
608
+ // in a production frame would allocate a record just to be sliced
609
+ // and dropped.
606
610
  const mountStartBuffer = [];
607
611
  const mountEndBuffer = [];
608
612
  const mountTracker = {
609
613
  started: (name, args) => {
614
+ if (devToolsStore === null) {
615
+ return;
616
+ }
610
617
  mountStartBuffer.push(args === undefined ? { name } : { name, args });
611
618
  },
612
619
  ended: (name, args) => {
620
+ if (devToolsStore === null) {
621
+ return;
622
+ }
613
623
  mountEndBuffer.push(args === undefined ? { name } : { name, args });
614
624
  },
615
625
  };
@@ -620,8 +630,38 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
620
630
  mountEndBuffer.length = 0;
621
631
  return { starts, ends };
622
632
  };
623
- const processMessage = (message) => Effect.gen(function* () {
624
- const currentModel = yield* Ref.get(modelRef);
633
+ // NOTE: the fork is deferred one microtask so a Command's Effect
634
+ // never begins on the dispatching stack. Commands are facts from
635
+ // outside the update loop; their results always arrive
636
+ // asynchronously, exactly as under the old queue. The fork runs
637
+ // through `Effect.runForkWith` (which starts its fiber
638
+ // synchronously, so the child is registered in `runtimeScope`
639
+ // before this callback returns), not `Effect.runSyncWith`:
640
+ // `runSyncWith` injects a temporary synchronous scheduler into the
641
+ // fiber context, the child would inherit it, and every later yield
642
+ // in the Command (for example, an op-budget suspension, or a
643
+ // Stream step) would
644
+ // reschedule through clamped `setTimeout` instead of the browser
645
+ // microtask scheduler carried by `runtimeContextForCommands`.
646
+ const forkCommand = (command, message) => {
647
+ queueMicrotask(() => {
648
+ // NOTE: `isCrashed` as well as `isRuntimeDisposed`. A crash is
649
+ // terminal but does not dispose the runtime, and a Command forked
650
+ // by a Message processed just before the crashing Message sits in
651
+ // this microtask when the crash view paints. Without the crash
652
+ // check its effect would run behind the crash view, contradicting
653
+ // the crash-terminality contract. `crashWith` sets `isCrashed`
654
+ // synchronously, so it is already set by the time this runs.
655
+ if (isRuntimeDisposed || isCrashed) {
656
+ return;
657
+ }
658
+ Effect.runForkWith(runtimeContextForCommands)(Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
659
+ attributes: command.args ?? {},
660
+ }), provideAllResources, Effect.flatMap(enqueueMessageEffect), Effect.catchCause(cause => crashWith(cause, message)))));
661
+ });
662
+ };
663
+ const processMessagePlain = (message) => {
664
+ const currentModel = liveModel;
625
665
  const [[nextModelRaw, commands], maybeUpdateDuration] = measureSlowPhase(resolvedSlowUpdate, () => update(currentModel, message));
626
666
  const nextModel = maybeFreezeModel(nextModelRaw);
627
667
  reportSlowPhase(resolvedSlowUpdate, maybeUpdateDuration, (durationMs, thresholdMs) => ({
@@ -633,46 +673,142 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
633
673
  thresholdMs,
634
674
  }));
635
675
  if (currentModel !== nextModel) {
636
- yield* Ref.set(modelRef, nextModel);
637
- yield* SubscriptionRef.set(isRenderPendingRef, true);
638
- yield* Ref.set(maybeLastDirtyMessageRef, Option.some(message));
676
+ liveModel = nextModel;
677
+ maybeLastDirtyMessage = Option.some(message);
639
678
  PubSub.publishUnsafe(modelPubSub, nextModel);
640
- yield* schedulePreserveModel(nextModel);
679
+ if (import.meta.hot) {
680
+ Effect.runSync(schedulePreserveModel(nextModel));
681
+ }
682
+ scheduleRenderFrame();
641
683
  }
642
684
  if (!Array.isReadonlyArrayEmpty(commands)) {
643
- yield* Effect.forEach(
644
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
645
- commands, command => Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
646
- attributes: command.args ?? {},
647
- }), provideAllResources, Effect.flatMap(enqueueNormal), Effect.catchCause(cause => crashWith(cause, Option.some(message))))));
685
+ for (const command of commands) {
686
+ forkCommand(
687
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
688
+ command, Option.some(message));
689
+ }
648
690
  }
649
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
650
- const tag = message._tag;
651
- const isModelChanged = currentModel !== nextModel;
652
- const isExcludedFromHistory = excludeFromHistoryTags.has(tag);
653
- if (Option.isSome(maybeDevToolsStore)) {
654
- const store = maybeDevToolsStore.value;
655
- if (!isExcludedFromHistory) {
656
- yield* store.recordMessage(
691
+ // NOTE: store writes go through `Effect.runFork`, not
692
+ // `Effect.runSync`. Both complete inline when the store's state
693
+ // Ref is uncontended (the always case on this path), but a
694
+ // DevTools fiber holding the Ref's permit across a yield would
695
+ // make `runSync` throw and crash the app; `runFork` parks and
696
+ // finishes the write when the permit frees, and the Ref's FIFO
697
+ // permit queue preserves write order.
698
+ if (devToolsStore !== null) {
699
+ const store = devToolsStore;
700
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
701
+ const tag = message._tag;
702
+ const isModelChanged = currentModel !== nextModel;
703
+ if (!excludeFromHistoryTags.has(tag)) {
704
+ Effect.runFork(store.recordMessage(
657
705
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
658
706
  message, currentModel, nextModel, Array.map(
659
707
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
660
- commands, toCommandRecord), isModelChanged);
708
+ commands, toCommandRecord), isModelChanged));
661
709
  }
662
710
  else if (isModelChanged) {
663
- yield* store.updateLatestModel(nextModel);
711
+ Effect.runFork(store.updateLatestModel(nextModel));
664
712
  }
665
713
  }
666
- });
714
+ };
715
+ // NOTE: escape hatch for synchronous bursts, so the page keeps
716
+ // painting under pathological load (for example, a fiber
717
+ // dispatching thousands of Messages in one task, or a fully
718
+ // synchronous Command chain). Bursts
719
+ // arrive as many single-Message drains within one browser task, so
720
+ // the budget is cumulative across drains: it accumulates processing
721
+ // time and resets when the browser demonstrably got control back (a
722
+ // render frame ran, or the gap since the last drain exceeds the
723
+ // budget). Once over budget, processing defers to a MessageChannel
724
+ // tick, which starts a new task so a pending frame can paint.
725
+ // setTimeout(0) would be clamped to 4ms+; MessageChannel delivers in
726
+ // ~0.5ms. The normal path pays two clock reads per drain.
727
+ let syncWorkMsSinceYield = 0;
728
+ let lastDrainEndedAt = 0;
729
+ let isDrainDeferredToNextTask = false;
730
+ let maybeDeferredDrainChannel = null;
731
+ const scheduleDeferredDrain = () => {
732
+ if (maybeDeferredDrainChannel === null) {
733
+ maybeDeferredDrainChannel = new MessageChannel();
734
+ maybeDeferredDrainChannel.port2.onmessage = () => {
735
+ isDrainDeferredToNextTask = false;
736
+ syncWorkMsSinceYield = 0;
737
+ drainPendingMessages();
738
+ };
739
+ }
740
+ isDrainDeferredToNextTask = true;
741
+ maybeDeferredDrainChannel.port1.postMessage(null);
742
+ };
743
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
744
+ if (maybeDeferredDrainChannel !== null) {
745
+ maybeDeferredDrainChannel.port1.close();
746
+ maybeDeferredDrainChannel.port2.close();
747
+ maybeDeferredDrainChannel = null;
748
+ }
749
+ }));
750
+ const drainPendingMessages = () => {
751
+ if (!isBootComplete ||
752
+ isProcessingMessages ||
753
+ isRenderingFrame ||
754
+ isDrainDeferredToNextTask ||
755
+ isRuntimeDisposed ||
756
+ isCrashed) {
757
+ return;
758
+ }
759
+ const drainStartedAt = performance.now();
760
+ if (drainStartedAt - lastDrainEndedAt > DRAIN_BUDGET_MS) {
761
+ syncWorkMsSinceYield = 0;
762
+ }
763
+ if (syncWorkMsSinceYield > DRAIN_BUDGET_MS) {
764
+ scheduleDeferredDrain();
765
+ return;
766
+ }
767
+ isProcessingMessages = true;
768
+ try {
769
+ while (pendingMessages.length > 0) {
770
+ const batch = pendingMessages;
771
+ pendingMessages = [];
772
+ for (let index = 0; index < batch.length; index++) {
773
+ const message = batch[index];
774
+ currentMessage = Option.some(message);
775
+ processMessagePlain(message);
776
+ const hasRemainingWork = index + 1 < batch.length || pendingMessages.length > 0;
777
+ if (hasRemainingWork &&
778
+ syncWorkMsSinceYield + (performance.now() - drainStartedAt) >
779
+ DRAIN_BUDGET_MS) {
780
+ // NOTE: unprocessed batch Messages arrived before
781
+ // anything in pendingMessages, so they go back to the
782
+ // front to keep arrival order.
783
+ pendingMessages = batch
784
+ .slice(index + 1)
785
+ .concat(pendingMessages);
786
+ scheduleDeferredDrain();
787
+ return;
788
+ }
789
+ }
790
+ }
791
+ }
792
+ catch (error) {
793
+ Effect.runFork(crashWith(Cause.die(error), currentMessage));
794
+ }
795
+ finally {
796
+ const drainEndedAt = performance.now();
797
+ syncWorkMsSinceYield += drainEndedAt - drainStartedAt;
798
+ lastDrainEndedAt = drainEndedAt;
799
+ isProcessingMessages = false;
800
+ }
801
+ };
667
802
  // NOTE: `dispatchService` defaults to the live dispatch but is
668
803
  // overridable so the DevTools jumpTo render path can pass
669
804
  // `noOpDispatch`. Mount Effects forked during a replay render still
670
805
  // execute (so the rendered DOM looks correct: positioning,
671
806
  // observer attachment, library setup), but their result Messages
672
- // reach a no-op dispatchSync and never enter the runtime queue.
807
+ // reach a no-op dispatchSync and are never processed.
673
808
  // This prevents mount-derived Messages from polluting history when
674
809
  // the user is just inspecting past state.
675
810
  const render = (model, message, dispatchService = dispatch, renderMode = 'Live') => Effect.gen(function* () {
811
+ isRenderingFrame = true;
676
812
  const runtimeContext = yield* Effect.context();
677
813
  const maybeLiveRender = Option.liftPredicate(renderMode, mode => mode === 'Live');
678
814
  if (renderMode === 'Replay') {
@@ -698,9 +834,9 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
698
834
  durationMs,
699
835
  thresholdMs,
700
836
  }));
701
- const maybeCurrentVNode = yield* Ref.get(maybeCurrentVNodeRef);
837
+ const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
702
838
  const [patchedVNode, maybePatchDuration] = yield* Effect.sync(() => measureSlowPhase(maybeLiveSlowPatch, () => __patchVNode(maybeCurrentVNode, nextVNode, container, boundaryRegistry.dedupeSeen)));
703
- yield* Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode));
839
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
704
840
  reportSlowPhase(maybeLiveSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
705
841
  _tag: 'Patch',
706
842
  model,
@@ -714,7 +850,11 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
714
850
  if (import.meta.hot) {
715
851
  yield* Effect.sync(() => duplicateIdScanner?.schedule(patchedVNode.elm));
716
852
  }
717
- }).pipe(Effect.ensuring(Effect.sync(() => endReplayHtmlRender())), Effect.provideService(Dispatch, dispatchService), Effect.provideService(MountTracker, mountTracker));
853
+ }).pipe(Effect.ensuring(Effect.sync(() => {
854
+ isRenderingFrame = false;
855
+ endReplayHtmlRender();
856
+ drainPendingMessages();
857
+ })), Effect.provideService(Dispatch, dispatchService), Effect.provideService(MountTracker, mountTracker));
718
858
  const isInIframe = window.self !== window.top;
719
859
  const resolvedDevTools = pipe(devTools ?? {}, Option.liftPredicate(config => config !== false), Option.filter(config => Match.value(config.show ?? DEFAULT_DEV_TOOLS_SHOW).pipe(Match.when('Always', () => true), Match.when('Development', () => !!import.meta.hot && !isInIframe), Match.exhaustive)), Option.map(config => ({
720
860
  position: config.position ?? DEFAULT_DEV_TOOLS_POSITION,
@@ -734,34 +874,28 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
734
874
  // direct lookup that reflects the real live state at the moment
735
875
  // the entry was recorded.
736
876
  const isExcludingMessages = excludeFromHistoryTags.size > 0;
737
- const devToolsStore = yield* createDevToolsStore({
877
+ const store = yield* createDevToolsStore({
738
878
  /* eslint-disable @typescript-eslint/consistent-type-assertions */
739
879
  replay: (model, message) => {
740
880
  const [updatedModel] = update(model, message);
741
881
  return maybeFreezeModel(updatedModel);
742
882
  },
743
883
  /* eslint-enable @typescript-eslint/consistent-type-assertions */
744
- // NOTE: clears the dirty bit on the jumpTo render so the
745
- // renderLoop's Stream.changes sees the next dispatch as a real
746
- // false-to-true transition rather than a deduped no-op. Passes
747
- // `noOpDispatch` so mount Effects forked during the replay
748
- // render dispatch their result Messages into a no-op (instead
749
- // of enqueueing them as new history entries). Also discards
750
- // mount events fired during the render so they don't get
751
- // attributed to the next user-initiated dispatch.
884
+ // NOTE: passes `noOpDispatch` so mount Effects forked during
885
+ // the replay render dispatch their result Messages into a
886
+ // no-op (instead of enqueueing them as new history entries).
887
+ // Also discards mount events fired during the render so they
888
+ // don't get attributed to the next user-initiated dispatch.
752
889
  render: model => Effect.gen(function* () {
753
- yield* SubscriptionRef.set(isRenderPendingRef, false);
754
890
  yield* render(
755
891
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
756
892
  model, Option.none(), noOpDispatch, 'Replay');
757
893
  drainMountEvents();
758
894
  }),
759
- // NOTE: `resume` calls this to wake the renderLoop after a
760
- // jumpTo render attached DOM listeners to `noOpDispatch`. The
761
- // false-to-true transition triggers one tick on the next
762
- // animation frame, which renders the live model with live
763
- // dispatch and rebinds listeners.
764
- markRenderPending: SubscriptionRef.set(isRenderPendingRef, true),
895
+ // NOTE: `resume` calls this after a jumpTo render attached DOM
896
+ // listeners to `noOpDispatch`. Scheduling a frame renders the
897
+ // live model with live dispatch and rebinds listeners.
898
+ markRenderPending: Effect.sync(() => scheduleRenderFrame()),
765
899
  }, {
766
900
  ...(devToolsKeyframeInterval !== undefined && {
767
901
  keyframeInterval: devToolsKeyframeInterval,
@@ -775,34 +909,24 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
775
909
  // Spread last so it wins over `keyframeInterval` above.
776
910
  ...(isExcludingMessages && { keyframeInterval: 1 }),
777
911
  });
778
- maybeDevToolsStore = Option.some(devToolsStore);
779
- // The init render runs below; capture the events it produces. We
780
- // record init AFTER that render so the buffer reflects the mounts
781
- // that fired on the first paint.
912
+ devToolsStore = store;
913
+ // NOTE: init is recorded after the init render below, so the
914
+ // mount buffer reflects the Mounts that fired on the first paint.
782
915
  yield* Option.match(maybeOverlay, {
783
916
  onNone: () => Effect.void,
784
- onSome: overlay => overlay(devToolsStore, position, mode, maybeBanner),
917
+ onSome: overlay => overlay(store, position, mode, maybeBanner),
785
918
  });
786
919
  if (import.meta.hot) {
787
920
  const maybeMessageSchema = devTools !== undefined && devTools !== false
788
921
  ? Option.fromNullishOr(devTools.Message)
789
922
  : Option.none();
790
- yield* startWebSocketBridge(devToolsStore, import.meta.hot,
923
+ yield* startWebSocketBridge(store, import.meta.hot,
791
924
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
792
- message => enqueueHigh(message),
925
+ message => enqueueMessageEffect(message),
793
926
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
794
927
  maybeMessageSchema);
795
928
  }
796
929
  }
797
- // NOTE: a fast-failing init Command (a `resources` Layer that
798
- // throws synchronously) can render the crash view before this
799
- // point. Rendering the init view would paint over it, so a crashed
800
- // runtime suspends here instead, exactly like the failing-init-
801
- // render path below.
802
- const isCrashedBeforeInitRender = yield* Ref.get(isCrashedRef);
803
- if (isCrashedBeforeInitRender) {
804
- return yield* Effect.never;
805
- }
806
930
  const initRenderExit = yield* Effect.exit(render(initModel, Option.none()));
807
931
  if (Exit.isFailure(initRenderExit)) {
808
932
  yield* crashWith(initRenderExit.cause, Option.none());
@@ -815,43 +939,105 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
815
939
  yield* restorePreservedScrollPosition(runtimeId);
816
940
  }
817
941
  const initMountEvents = drainMountEvents();
818
- yield* Option.match(maybeDevToolsStore, {
819
- onNone: () => Effect.void,
820
- onSome: store => store.recordInit(initModel, Array.map(initCommands, toCommandRecord), initMountEvents.starts),
821
- });
822
- // NOTE: maybeLastDirtyMessageRef holds the most recent dirtying
942
+ if (devToolsStore !== null) {
943
+ yield* devToolsStore.recordInit(initModel, Array.map(initCommands, toCommandRecord), initMountEvents.starts);
944
+ }
945
+ // NOTE: maybeLastDirtyMessage holds the most recent dirtying
823
946
  // Message, so slow render-phase callbacks during high-rate bursts attribute
824
947
  // to the last Message in the frame batch, not the specific one that
825
948
  // pushed the view past threshold. Acceptable for a debug callback;
826
949
  // full attribution would require correlating each message with its
827
950
  // render contribution, which isn't worth the complexity.
828
- const renderLoop = makeRenderLoop({
829
- pendingRef: isRenderPendingRef,
830
- awaitNextFrame,
831
- isPaused: isPausedEffect,
832
- render: Effect.gen(function* () {
833
- // NOTE: a Message that dirtied the model can also be the one
834
- // whose Command crashed the runtime. Without this guard the
835
- // next animation frame would render the live view over the
836
- // crash view.
837
- const isCrashed = yield* Ref.get(isCrashedRef);
838
- if (isCrashed) {
839
- return;
951
+ // NOTE: render frames run as plain JavaScript inside the
952
+ // requestAnimationFrame callback. Messages arriving between frames
953
+ // mark at most one pending frame; the callback renders once with the
954
+ // latest model. The runtime context for OnMount forking and Command
955
+ // forking is captured once here; it is constant for the lifetime of
956
+ // the runtime.
957
+ const runtimeContextForCommands = yield* Effect.context();
958
+ const liveRenderContext = Context.add(Context.add(runtimeContextForCommands, Dispatch, dispatch), MountTracker, mountTracker);
959
+ const renderFramePlain = () => {
960
+ isRenderFrameScheduled = false;
961
+ // NOTE: a frame scheduled before disposal fires after it; a
962
+ // disposed runtime must not repaint the released container.
963
+ if (isRuntimeDisposed) {
964
+ return;
965
+ }
966
+ // NOTE: a frame is running, so the browser got control back; the
967
+ // drain budget starts fresh.
968
+ syncWorkMsSinceYield = 0;
969
+ // NOTE: a Message that dirtied the model can also be the one
970
+ // whose Command crashed the runtime. Without this guard the
971
+ // next animation frame would render the live view over the
972
+ // crash view.
973
+ if (isCrashed) {
974
+ return;
975
+ }
976
+ if (isPausedNow()) {
977
+ return;
978
+ }
979
+ isRenderingFrame = true;
980
+ try {
981
+ renderSyncPlain(liveModel, maybeLastDirtyMessage);
982
+ if (devToolsStore !== null) {
983
+ const mountEvents = drainMountEvents();
984
+ Effect.runFork(devToolsStore.attachRenderedMounts(mountEvents.starts, mountEvents.ends));
840
985
  }
841
- const model = yield* Ref.get(modelRef);
842
- const maybeMessage = yield* Ref.get(maybeLastDirtyMessageRef);
843
- yield* render(model, maybeMessage);
844
- const mountEvents = drainMountEvents();
845
- yield* Option.match(maybeDevToolsStore, {
846
- onNone: () => Effect.void,
847
- onSome: store => store.attachRenderedMounts(mountEvents.starts, mountEvents.ends),
848
- });
849
- }),
850
- });
851
- yield* Effect.forkIn(runtimeScope)(renderLoop.pipe(Effect.catchCause(cause => Effect.gen(function* () {
852
- const maybeMessage = yield* Ref.get(maybeLastDirtyMessageRef);
853
- yield* crashWith(cause, maybeMessage);
854
- }))));
986
+ }
987
+ catch (error) {
988
+ Effect.runFork(crashWith(Cause.die(error), maybeLastDirtyMessage));
989
+ }
990
+ finally {
991
+ isRenderingFrame = false;
992
+ }
993
+ // NOTE: Messages dispatched by patch-time hooks (for example,
994
+ // OnUnmount destroys, or Mount emissions) were buffered while the
995
+ // frame held the stack; they process now, after the patch has
996
+ // committed and the frame's Mount events are attributed.
997
+ drainPendingMessages();
998
+ };
999
+ const renderSyncPlain = (model, maybeMessage) => {
1000
+ const [nextDocument, maybeViewDuration] = measureSlowPhase(resolvedSlowView, () => {
1001
+ beginHtmlRender(boundaryRegistry);
1002
+ setHtmlRuntime(dispatch.dispatchSync, liveRenderContext, boundaryRegistry);
1003
+ try {
1004
+ return view(model);
1005
+ }
1006
+ finally {
1007
+ clearHtmlRuntime();
1008
+ }
1009
+ });
1010
+ reportSlowPhase(resolvedSlowView, maybeViewDuration, (durationMs, thresholdMs) => ({
1011
+ _tag: 'View',
1012
+ model,
1013
+ message: maybeMessage,
1014
+ durationMs,
1015
+ thresholdMs,
1016
+ }));
1017
+ const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
1018
+ const [patchedVNode, maybePatchDuration] = measureSlowPhase(resolvedSlowPatch, () => __patchVNode(maybeCurrentVNode, nextDocument.body, container, boundaryRegistry.dedupeSeen));
1019
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
1020
+ reportSlowPhase(resolvedSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
1021
+ _tag: 'Patch',
1022
+ model,
1023
+ message: maybeMessage,
1024
+ durationMs,
1025
+ thresholdMs,
1026
+ }));
1027
+ if (manageDocument) {
1028
+ applyDocumentMetadata(nextDocument, patchedVNode.elm);
1029
+ }
1030
+ if (import.meta.hot) {
1031
+ duplicateIdScanner?.schedule(patchedVNode.elm);
1032
+ }
1033
+ };
1034
+ const scheduleRenderFrame = () => {
1035
+ if (isRenderFrameScheduled) {
1036
+ return;
1037
+ }
1038
+ isRenderFrameScheduled = true;
1039
+ requestAnimationFrame(renderFramePlain);
1040
+ };
855
1041
  // NOTE: reloading on bfcache restore is a page-level decision, so
856
1042
  // only a page-owning runtime that manages the document installs the
857
1043
  // listener. An app started through `embed` carries a host connector
@@ -907,7 +1093,7 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
907
1093
  })));
908
1094
  yield* Effect.forkIn(runtimeScope)(Stream.concat(Stream.make(initDependencies), modelChangesStream).pipe(Stream.changesWith(equivalence), Stream.switchMap(dependencies => dependenciesToStream(dependencies, () => Ref.getUnsafe(latestDependenciesRef))), Stream.runForEach(message =>
909
1095
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
910
- enqueueHigh(message)), provideAllResources, Effect.catchCause(cause => crashWith(cause, Option.none()))));
1096
+ enqueueMessageEffect(message)), provideAllResources, Effect.catchCause(cause => crashWith(cause, Option.none()))));
911
1097
  }), {
912
1098
  concurrency: 'unbounded',
913
1099
  discard: true,
@@ -929,79 +1115,56 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
929
1115
  const release = (value) => Effect.gen(function* () {
930
1116
  yield* config.release(value);
931
1117
  yield* Ref.set(resourceRef, Option.none());
932
- yield* enqueueHigh(config.onReleased());
1118
+ yield* enqueueMessageEffect(config.onReleased());
933
1119
  }).pipe(Effect.catchCause(() => Effect.void));
934
1120
  return pipe(Stream.scoped(Stream.fromEffect(Effect.acquireRelease(acquire, release))), Stream.flatMap(value => Stream.concat(Stream.make(config.onAcquired(value)), Stream.never)), Stream.map(Effect.succeed), Stream.catch(error => Stream.make(Effect.succeed(config.onAcquireError(error)))));
935
1121
  };
936
1122
  const forkManagedResourceLifecycle = ({ config, ref: resourceRef, }) => Effect.gen(function* () {
937
1123
  const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
938
1124
  const equivalence = Schema.toEquivalence(config.schema);
939
- yield* Effect.forkIn(runtimeScope)(modelStream.pipe(Stream.map(config.modelToMaybeRequirements), Stream.changesWith(equivalence), Stream.switchMap(maybeRequirementsToLifecycle(config, resourceRef)), Stream.runForEach(Effect.flatMap(enqueueHigh))));
1125
+ yield* Effect.forkIn(runtimeScope)(modelStream.pipe(Stream.map(config.modelToMaybeRequirements), Stream.changesWith(equivalence), Stream.switchMap(maybeRequirementsToLifecycle(config, resourceRef)), Stream.runForEach(Effect.flatMap(enqueueMessageEffect)),
1126
+ // NOTE: mirrors the Subscription fork so a defect in
1127
+ // `modelToMaybeRequirements` or the equivalence surfaces as
1128
+ // the crash view instead of dying silently in this detached
1129
+ // fiber. `provideAllResources` is not needed: `acquire` only
1130
+ // requires `Scope`, which `Stream.scoped` supplies, and
1131
+ // `release` requires nothing.
1132
+ Effect.catchCause(cause => crashWith(cause, Option.none()))));
940
1133
  });
941
1134
  yield* Effect.forEach(managedResourceRefs, forkManagedResourceLifecycle, {
942
1135
  concurrency: 'unbounded',
943
1136
  discard: true,
944
1137
  });
945
- const processWithBudget = (message) => Effect.gen(function* () {
946
- currentMessage = Option.some(message);
947
- yield* processMessage(message);
948
- if (performance.now() - burstStartedAt < FRAME_BUDGET_MS) {
949
- return;
950
- }
951
- yield* yieldToBrowser;
952
- burstStartedAt = performance.now();
953
- });
954
- const processBatch = (batch) => Effect.forEach(orderByPriority(batch), processWithBudget, {
955
- discard: true,
956
- });
957
- // NOTE: Effect 4's `Queue.takeAll` blocks until at least one message
958
- // arrives (it's `takeBetween(self, 1, ∞)`, not a non-blocking
959
- // snapshot). For batching we want "give me whatever is currently in
960
- // the queue, possibly nothing" so we drain via repeated `Queue.poll`
961
- // until it returns `None`.
962
- const pollAvailable = Effect.gen(function* () {
963
- const accumulated = [];
964
- while (true) {
965
- const next = yield* Queue.poll(messageQueue);
966
- if (Option.isNone(next)) {
967
- return accumulated;
968
- }
969
- accumulated.push(next.value);
970
- }
971
- });
972
- const drainQueue = Effect.gen(function* () {
973
- const batch = yield* pollAvailable;
974
- if (Array.isReadonlyArrayEmpty(batch)) {
975
- return;
976
- }
977
- yield* processBatch(batch);
978
- yield* drainQueue;
979
- });
980
- // NOTE: only reset the burst timer when `Queue.take` actually blocked
981
- // (queue was empty). With Command-chained dispatches each forever
982
- // iteration handles a single message, so resetting unconditionally
983
- // would keep the per-iteration cost under FRAME_BUDGET_MS forever
984
- // and the runtime would never yield to the browser. Polling first
985
- // distinguishes "continuing a burst" (poll returns Some) from
986
- // "waking from idle" (poll returns None, take blocks).
987
- yield* pipe(Effect.forever(Effect.gen(function* () {
988
- const maybeFirst = yield* Queue.poll(messageQueue);
989
- const first = yield* Option.match(maybeFirst, {
990
- onNone: () => Effect.gen(function* () {
991
- const message = yield* Queue.take(messageQueue);
992
- burstStartedAt = performance.now();
993
- return message;
994
- }),
995
- onSome: Effect.succeed,
996
- });
997
- const rest = yield* pollAvailable;
998
- yield* processBatch(Array.prepend(rest, first));
999
- yield* drainQueue;
1000
- })), Effect.catchCause(cause => crashWith(cause, currentMessage)));
1001
- // NOTE: reached only after the drain loop crashed and the crash view
1002
- // rendered. Suspending keeps the runtime scope open so the crash view
1003
- // and the DevTools overlay stay up for inspection; interruption
1004
- // (dispose, or page unload) still tears everything down.
1138
+ // NOTE: registered before the boot buffer drains, so an interrupt
1139
+ // landing anywhere after this yield tears down with the flag set
1140
+ // (finalizers are LIFO; this one runs before every
1141
+ // earlier-registered teardown, including the container-restoring
1142
+ // patch whose OnUnmount dispatches must be dropped). An interrupt
1143
+ // landing before this yield tears down with isBootComplete still
1144
+ // false, so every dispatch buffers and dies with the closure.
1145
+ // Either way no Message is processed against a closing runtime.
1146
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
1147
+ isRuntimeDisposed = true;
1148
+ }));
1149
+ // NOTE: init Commands fork as the last act of boot, exactly where
1150
+ // the old queue's drain loop used to start. Together with the
1151
+ // isBootComplete barrier this guarantees no Command result (or any
1152
+ // other Message) is processed until the init render has painted
1153
+ // initModel and every boot subsystem (DevTools store, Subscriptions,
1154
+ // ManagedResources, ports) is attached. forkCommand also defers each
1155
+ // start by a microtask, so a fully synchronous init Command still
1156
+ // delivers its result asynchronously.
1157
+ for (const command of initCommands) {
1158
+ forkCommand(
1159
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1160
+ command, Option.none());
1161
+ }
1162
+ isBootComplete = true;
1163
+ drainPendingMessages();
1164
+ // NOTE: suspend forever. Messages are processed synchronously on
1165
+ // the dispatching stack and render frames run as plain rAF
1166
+ // callbacks, so this fiber's only remaining job is keeping the
1167
+ // runtime scope open until interruption (dispose, or page unload).
1005
1168
  yield* Effect.never;
1006
1169
  }));
1007
1170
  const start = (hmrModel) => startWith(Option.none(), hmrModel);
@@ -1013,16 +1176,37 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
1013
1176
  });
1014
1177
  return program;
1015
1178
  };
1179
+ // NOTE: how long one synchronous drain may hold the stack before the
1180
+ // remaining Messages defer to a new task so the browser can paint. Only
1181
+ // multi-Message bursts ever reach the check; the single-Message path never
1182
+ // reads the clock beyond the drain start.
1183
+ const DRAIN_BUDGET_MS = 5;
1016
1184
  const currentLocationUrl = () => {
1017
1185
  const { origin, pathname, search } = window.location;
1018
1186
  return `${origin}${pathname}${search}`;
1019
1187
  };
1020
- const upsertHeadElement = (tagName, selector, attributes) => {
1021
- const existing = document.head.querySelector(selector);
1022
- const element = existing ?? document.head.appendChild(document.createElement(tagName));
1023
- Object.entries(attributes).forEach(([key, value]) => {
1024
- element.setAttribute(key, value);
1025
- });
1188
+ const documentMetadataElements = new WeakMap();
1189
+ const metadataElementsForDocument = () => {
1190
+ let elements = documentMetadataElements.get(document);
1191
+ if (elements === undefined) {
1192
+ elements = {};
1193
+ documentMetadataElements.set(document, elements);
1194
+ }
1195
+ let canonical = elements.canonical;
1196
+ if (canonical === undefined || canonical.parentNode !== document.head) {
1197
+ canonical =
1198
+ document.head.querySelector('link[rel="canonical"]') ??
1199
+ document.head.appendChild(document.createElement('link'));
1200
+ elements.canonical = canonical;
1201
+ }
1202
+ let ogUrl = elements.ogUrl;
1203
+ if (ogUrl === undefined || ogUrl.parentNode !== document.head) {
1204
+ ogUrl =
1205
+ document.head.querySelector('meta[property="og:url"]') ??
1206
+ document.head.appendChild(document.createElement('meta'));
1207
+ elements.ogUrl = ogUrl;
1208
+ }
1209
+ return { canonical, ogUrl };
1026
1210
  };
1027
1211
  const applyDocumentMetadata = (nextDocument, mountedRoot) => {
1028
1212
  if (!mountedRoot || !document.body.contains(mountedRoot)) {
@@ -1033,16 +1217,21 @@ const applyDocumentMetadata = (nextDocument, mountedRoot) => {
1033
1217
  }
1034
1218
  const canonical = nextDocument.canonical ?? currentLocationUrl();
1035
1219
  const ogUrl = nextDocument.ogUrl ?? canonical;
1036
- upsertHeadElement('link', 'link[rel="canonical"]', {
1037
- rel: 'canonical',
1038
- href: canonical,
1039
- });
1040
- upsertHeadElement('meta', 'meta[property="og:url"]', {
1041
- property: 'og:url',
1042
- content: ogUrl,
1043
- });
1220
+ const metadataElements = metadataElementsForDocument();
1221
+ if (metadataElements.canonical.getAttribute('rel') !== 'canonical') {
1222
+ metadataElements.canonical.setAttribute('rel', 'canonical');
1223
+ }
1224
+ if (metadataElements.canonical.getAttribute('href') !== canonical) {
1225
+ metadataElements.canonical.setAttribute('href', canonical);
1226
+ }
1227
+ if (metadataElements.ogUrl.getAttribute('property') !== 'og:url') {
1228
+ metadataElements.ogUrl.setAttribute('property', 'og:url');
1229
+ }
1230
+ if (metadataElements.ogUrl.getAttribute('content') !== ogUrl) {
1231
+ metadataElements.ogUrl.setAttribute('content', ogUrl);
1232
+ }
1044
1233
  };
1045
- const renderCrashView = (context, crash, container, maybeCurrentVNodeRef, manageDocument) => {
1234
+ const renderCrashView = (context, crash, container, vnodeSlot, manageDocument) => {
1046
1235
  console.error('[foldkit] Application crash:', context.error);
1047
1236
  if (crash?.report) {
1048
1237
  try {
@@ -1067,9 +1256,8 @@ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef, manage
1067
1256
  finally {
1068
1257
  clearHtmlRuntime();
1069
1258
  }
1070
- const maybeCurrentVNode = Effect.runSync(Ref.get(maybeCurrentVNodeRef));
1071
- const patchedVNode = __patchVNode(maybeCurrentVNode, crashDocument.body, container);
1072
- Effect.runSync(Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode)));
1259
+ const patchedVNode = __patchVNode(vnodeSlot.maybeCurrentVNode, crashDocument.body, container);
1260
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
1073
1261
  if (manageDocument) {
1074
1262
  applyDocumentMetadata(crashDocument, patchedVNode.elm);
1075
1263
  }
@@ -1085,9 +1273,8 @@ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef, manage
1085
1273
  finally {
1086
1274
  clearHtmlRuntime();
1087
1275
  }
1088
- const maybeCurrentVNode = Effect.runSync(Ref.get(maybeCurrentVNodeRef));
1089
- const patchedVNode = __patchVNode(maybeCurrentVNode, fallbackDocument.body, container);
1090
- Effect.runSync(Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode)));
1276
+ const patchedVNode = __patchVNode(vnodeSlot.maybeCurrentVNode, fallbackDocument.body, container);
1277
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
1091
1278
  if (manageDocument) {
1092
1279
  applyDocumentMetadata(fallbackDocument, patchedVNode.elm);
1093
1280
  }
@@ -1252,9 +1439,12 @@ const PLUGIN_RESPONSE_TIMEOUT_MS = 500;
1252
1439
  // NOTE: scheduling fix for browser performance. Effect needs to defer work
1253
1440
  // onto a future tick of the event loop. The default browser scheduler picks
1254
1441
  // `setTimeout(f, 0)`, but browsers clamp `setTimeout` to a minimum of 4ms.
1255
- // `queueMicrotask` runs on the very next tick (sub-millisecond). Without this
1256
- // override, every dispatched message takes an extra 4-16ms round-trip,
1257
- // sharply visible on hover and drag.
1442
+ // `queueMicrotask` runs on the very next tick (sub-millisecond). Dispatch no
1443
+ // longer routes through the Effect scheduler, but Command and Subscription
1444
+ // fibers still do; without this override every fiber yield (for example, an
1445
+ // op-budget suspension, or a Stream step) would take an extra 4-16ms
1446
+ // round-trip before
1447
+ // its result Message lands.
1258
1448
  const microtaskSetImmediate = (callback) => {
1259
1449
  let cancelled = false;
1260
1450
  queueMicrotask(() => {