foldkit 0.128.1 → 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 (104) 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/command/index.d.ts +6 -1
  14. package/dist/command/index.d.ts.map +1 -1
  15. package/dist/command/interruptible/index.d.ts +2 -0
  16. package/dist/command/interruptible/index.d.ts.map +1 -0
  17. package/dist/command/interruptible/index.js +1 -0
  18. package/dist/command/interruptible/interruptible.d.ts +177 -0
  19. package/dist/command/interruptible/interruptible.d.ts.map +1 -0
  20. package/dist/command/interruptible/interruptible.js +155 -0
  21. package/dist/command/interruptible/public.d.ts +3 -0
  22. package/dist/command/interruptible/public.d.ts.map +1 -0
  23. package/dist/command/interruptible/public.js +1 -0
  24. package/dist/command/public.d.ts +1 -0
  25. package/dist/command/public.d.ts.map +1 -1
  26. package/dist/command/public.js +1 -0
  27. package/dist/devTools/protocol.d.ts +1 -1
  28. package/dist/devTools/protocol.d.ts.map +1 -1
  29. package/dist/devTools/protocol.js +1 -1
  30. package/dist/devTools/store.js +2 -2
  31. package/dist/devTools/webSocketBridge.d.ts +1 -1
  32. package/dist/devTools/webSocketBridge.js +1 -1
  33. package/dist/html/index.d.ts +18 -18
  34. package/dist/html/index.d.ts.map +1 -1
  35. package/dist/html/index.js +530 -460
  36. package/dist/html/lazy.d.ts +1 -1
  37. package/dist/html/lazy.d.ts.map +1 -1
  38. package/dist/html/lazy.js +11 -2
  39. package/dist/propsModule.d.ts +1 -1
  40. package/dist/propsModule.d.ts.map +1 -1
  41. package/dist/propsModule.js +6 -1
  42. package/dist/runtime/runtime.d.ts.map +1 -1
  43. package/dist/runtime/runtime.js +472 -279
  44. package/dist/snabbdom/attributes.d.ts +4 -0
  45. package/dist/snabbdom/attributes.d.ts.map +1 -0
  46. package/dist/snabbdom/attributes.js +63 -0
  47. package/dist/snabbdom/class.d.ts +4 -0
  48. package/dist/snabbdom/class.d.ts.map +1 -0
  49. package/dist/snabbdom/class.js +31 -0
  50. package/dist/snabbdom/dataset.d.ts +4 -0
  51. package/dist/snabbdom/dataset.d.ts.map +1 -0
  52. package/dist/snabbdom/dataset.js +42 -0
  53. package/dist/snabbdom/eventlisteners.d.ts +11 -0
  54. package/dist/snabbdom/eventlisteners.d.ts.map +1 -0
  55. package/dist/snabbdom/eventlisteners.js +85 -0
  56. package/dist/snabbdom/h.d.ts +11 -0
  57. package/dist/snabbdom/h.d.ts.map +1 -0
  58. package/dist/snabbdom/h.js +61 -0
  59. package/dist/snabbdom/hooks.d.ts +24 -0
  60. package/dist/snabbdom/hooks.d.ts.map +1 -0
  61. package/dist/snabbdom/hooks.js +1 -0
  62. package/dist/snabbdom/htmldomapi.d.ts +34 -0
  63. package/dist/snabbdom/htmldomapi.d.ts.map +1 -0
  64. package/dist/snabbdom/htmldomapi.js +111 -0
  65. package/dist/snabbdom/index.d.ts +15 -0
  66. package/dist/snabbdom/index.d.ts.map +1 -0
  67. package/dist/snabbdom/index.js +9 -0
  68. package/dist/snabbdom/init.d.ts +14 -0
  69. package/dist/snabbdom/init.d.ts.map +1 -0
  70. package/dist/snabbdom/init.js +486 -0
  71. package/dist/snabbdom/is.d.ts +3 -0
  72. package/dist/snabbdom/is.d.ts.map +1 -0
  73. package/dist/snabbdom/is.js +7 -0
  74. package/dist/snabbdom/module.d.ts +11 -0
  75. package/dist/snabbdom/module.d.ts.map +1 -0
  76. package/dist/snabbdom/module.js +1 -0
  77. package/dist/snabbdom/props.d.ts +2 -0
  78. package/dist/snabbdom/props.d.ts.map +1 -0
  79. package/dist/snabbdom/props.js +1 -0
  80. package/dist/snabbdom/style.d.ts +9 -0
  81. package/dist/snabbdom/style.d.ts.map +1 -0
  82. package/dist/snabbdom/style.js +123 -0
  83. package/dist/snabbdom/tovnode.d.ts +4 -0
  84. package/dist/snabbdom/tovnode.d.ts.map +1 -0
  85. package/dist/snabbdom/tovnode.js +69 -0
  86. package/dist/snabbdom/vnode.d.ts +46 -0
  87. package/dist/snabbdom/vnode.d.ts.map +1 -0
  88. package/dist/snabbdom/vnode.js +13 -0
  89. package/dist/test/apps/uploads.d.ts +81 -0
  90. package/dist/test/apps/uploads.d.ts.map +1 -0
  91. package/dist/test/apps/uploads.js +102 -0
  92. package/dist/test/internal.d.ts +16 -2
  93. package/dist/test/internal.d.ts.map +1 -1
  94. package/dist/test/internal.js +20 -4
  95. package/dist/vdom.d.ts +2 -2
  96. package/dist/vdom.d.ts.map +1 -1
  97. package/dist/vdom.js +1 -1
  98. package/package.json +7 -5
  99. package/dist/runtime/messagePriority.d.ts +0 -17
  100. package/dist/runtime/messagePriority.d.ts.map +0 -1
  101. package/dist/runtime/messagePriority.js +0 -33
  102. package/dist/runtime/renderLoop.d.ts +0 -27
  103. package/dist/runtime/renderLoop.d.ts.map +0 -1
  104. package/dist/runtime/renderLoop.js +0 -25
@@ -1,5 +1,6 @@
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
+ import { __CurrentRegistry as __CurrentInterruptRegistry, __makeRegistry as __makeInterruptRegistry, } from '../command/interruptible/index.js';
3
4
  import { createDevToolsStore, } from '../devTools/store.js';
4
5
  import { startWebSocketBridge } from '../devTools/webSocketBridge.js';
5
6
  import { __beginRender as beginHtmlRender, __beginReplayRender as beginReplayHtmlRender, __clearRuntime as clearHtmlRuntime, __createBoundaryRegistry as createHtmlBoundaryRegistry, __endReplayRender as endReplayHtmlRender, __setRuntime as setHtmlRuntime, } from '../html/index.js';
@@ -12,9 +13,7 @@ import { defaultCrashView, noOpDispatch } from './crashUI.js';
12
13
  import { deepFreeze } from './deepFreeze.js';
13
14
  import { PreserveModelMessage, RequestModelMessage, RestoreModelMessage, } from './hmrProtocol.js';
14
15
  import { preserveScrollPosition, restorePreservedScrollPosition, } from './hmrScroll.js';
15
- import { orderByPriority } from './messagePriority.js';
16
16
  import { makePreserveScheduler } from './preserveScheduler.js';
17
- import { makeRenderLoop } from './renderLoop.js';
18
17
  const toCommandRecord = (command) => command.args !== undefined
19
18
  ? { name: command.name, args: command.args }
20
19
  : { name: command.name };
@@ -338,57 +337,17 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
338
337
  validatePorts(ports);
339
338
  }
340
339
  const runtimeId = container?.id ?? '';
341
- // NOTE: When the message queue drains a chain of dispatches (e.g. recursive
342
- // Commands, websocket bursts), processing all of them inside one macrotask
343
- // blocks the browser from painting. Yield via MessageChannel once the
344
- // current burst exceeds FRAME_BUDGET_MS so the browser gets a frame.
345
- // setTimeout(0) is clamped to 4ms+; MessageChannel delivers in ~0.5ms.
346
- const FRAME_BUDGET_MS = 5;
347
- // NOTE: render coalescing relies on this firing once per frame. Multiple
348
- // Messages dispatched between frames all flag the renderLoop dirty; the
349
- // next rAF tick reads the latest model and renders once. Without this,
350
- // every Message would call render() inline, and during high-rate streams
351
- // (drag pointermove, websocket bursts) the runtime would paint each
352
- // intermediate frame with the cursor leading the rendered position.
353
- const awaitNextFrame = Effect.callback(resume => {
354
- const handle = requestAnimationFrame(() => resume(Effect.void));
355
- return Effect.sync(() => cancelAnimationFrame(handle));
356
- });
357
340
  const startWith = (maybeConnector, hmrModel) => Effect.scoped(Effect.gen(function* () {
358
341
  if (runtimeId === '') {
359
342
  return yield* Effect.die(new Error('[foldkit] Runtime container must have an `id` for HMR model preservation. ' +
360
343
  'Set `container.id = "app"` (or any unique string) before passing it to makeApplication or makeElement.'));
361
344
  }
362
- // NOTE: every perpetual fiber (render loop, Subscription streams,
363
- // ManagedResource lifecycles) and every Command fiber forks into the
364
- // runtime scope, so interrupting the runtime fiber (what dispose
365
- // does) interrupts them all and runs their finalizers. A detached
366
- // 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.
367
350
  const runtimeScope = yield* Effect.scope;
368
- // NOTE: one persistent MessageChannel for the runtime lifetime,
369
- // shared by every burst-budget yield. The queue-drain fiber is the
370
- // sole consumer, so a single `pendingYieldResume` slot is sufficient.
371
- const yieldChannel = yield* Effect.acquireRelease(Effect.sync(() => new MessageChannel()), channel => Effect.sync(() => {
372
- channel.port1.close();
373
- channel.port2.close();
374
- }));
375
- let pendingYieldResume = null;
376
- yieldChannel.port2.onmessage = () => {
377
- const resume = pendingYieldResume;
378
- pendingYieldResume = null;
379
- if (resume !== null) {
380
- resume(Effect.void);
381
- }
382
- };
383
- const yieldToBrowser = Effect.callback(resume => {
384
- pendingYieldResume = resume;
385
- yieldChannel.port1.postMessage(null);
386
- return Effect.sync(() => {
387
- if (pendingYieldResume === resume) {
388
- pendingYieldResume = null;
389
- }
390
- });
391
- });
392
351
  // NOTE: `Effect.provide(effect, layer)` builds the Layer into a
393
352
  // scope that closes when the provided effect ends, so providing the
394
353
  // Layer per Command would construct and tear down every resource on
@@ -434,6 +393,7 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
434
393
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
435
394
  Layer.empty, mergeResourceIntoLayer)),
436
395
  });
396
+ const interruptRegistry = __makeInterruptRegistry();
437
397
  const provideAllResources = (effect) => {
438
398
  const withResources = Option.match(maybeAcquireResourceContext, {
439
399
  onNone: () => effect,
@@ -446,10 +406,11 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
446
406
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
447
407
  Effect.provide(withResources, managedLayer),
448
408
  });
449
- return Option.match(maybePortChannels, {
409
+ const withPortChannels = Option.match(maybePortChannels, {
450
410
  onNone: () => withManagedResources,
451
411
  onSome: portChannels => Effect.provideService(withManagedResources, __CurrentPortChannels, portChannels.channels),
452
412
  });
413
+ return Effect.provideService(withPortChannels, __CurrentInterruptRegistry, interruptRegistry);
453
414
  };
454
415
  const flags = yield* resolveFlags;
455
416
  const ModelJsonCodec = Schema.toCodecJson(
@@ -493,18 +454,64 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
493
454
  }), handler => Effect.sync(() => hot.off('vite:beforeFullReload', handler)));
494
455
  }
495
456
  const schedulePreserveModel = (model) => hot ? preserveScheduler.schedule(model) : Effect.void;
496
- // NOTE: Each enqueued Message carries a priority. Within a single
497
- // takeAll batch the drain loop processes all High before any Normal,
498
- // so user input (view dispatch, navigation, subscription events,
499
- // managed-resource events, external dispatchers) lands ahead of
500
- // chain-derived work (Command results) when they share a frame.
501
- // FIFO order is preserved within a priority class.
502
- const messageQueue = yield* Queue.unbounded();
503
- const enqueueHigh = (message) => Queue.offer(messageQueue, { priority: 'High', message });
504
- const enqueueNormal = (message) => Queue.offer(messageQueue, { priority: 'Normal', message });
505
- const enqueueHighUnsafe = (message) => {
506
- 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();
507
513
  };
514
+ const enqueueMessageEffect = (message) => Effect.sync(() => enqueueMessage(message));
508
515
  const currentUrl = Option.fromNullishOr(routingConfig).pipe(Option.flatMap(() => urlFromString(window.location.href)));
509
516
  const [initModelRaw, initCommands] = Predicate.isNotUndefined(hmrModel)
510
517
  ? Exit.match(decodeHmrModel(hmrModel), {
@@ -518,10 +525,15 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
518
525
  yield* Effect.addFinalizer(() => Effect.sync(() => duplicateIdScanner?.cancel()));
519
526
  }
520
527
  if (routingConfig) {
521
- 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()));
522
529
  }
523
- const modelRef = yield* Ref.make(initModel);
524
- 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() };
525
537
  // NOTE: registered before any perpetual fiber is forked so it runs
526
538
  // after they are interrupted (scope finalizers are LIFO). Patching to
527
539
  // an empty tree fires snabbdom destroy hooks, which is what releases
@@ -535,7 +547,7 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
535
547
  if (!Exit.hasInterrupts(exit)) {
536
548
  return;
537
549
  }
538
- const maybeCurrentVNode = yield* Ref.get(maybeCurrentVNodeRef);
550
+ const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
539
551
  yield* Option.match(maybeCurrentVNode, {
540
552
  onNone: () => Effect.void,
541
553
  onSome: currentVNode => Effect.sync(() => {
@@ -547,66 +559,67 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
547
559
  }),
548
560
  });
549
561
  }));
550
- const isCrashedRef = yield* Ref.make(false);
551
- // NOTE: shared by every fiber's crash path: init render, render
552
- // loop, message drain, and the Command and Subscription forks (a
553
- // Command's Effect and a Subscription's Stream are typed with a
554
- // `never` error channel, so a cause escaping one can only be a
555
- // `resources` Layer build failure or an escaped defect, both
556
- // unrecoverable). Each fiber catches its own cause so
557
- // a failure surfaces as the crash view instead of dying silently
558
- // and leaving the DOM frozen at the last successful render. The
559
- // first crash wins: concurrent Command fibers can fail on the same
560
- // broken Layer, and only one should report and render.
561
- const crashWith = (cause, maybeMessage) => Effect.gen(function* () {
562
- const wasCrashed = yield* Ref.getAndSet(isCrashedRef, true);
563
- 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) {
564
575
  return;
565
576
  }
566
- const model = yield* Ref.get(modelRef);
577
+ isCrashed = true;
578
+ const model = liveModel;
567
579
  const squashed = Cause.squash(cause);
568
580
  const error = squashed instanceof Error ? squashed : new Error(String(squashed));
569
- renderCrashView({ error, model, message: maybeMessage }, crash, container, maybeCurrentVNodeRef, manageDocument);
581
+ renderCrashView({ error, model, message: maybeMessage }, crash, container, vnodeSlot, manageDocument);
570
582
  });
571
- yield* Effect.forEach(
572
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
573
- initCommands, command => Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
574
- attributes: command.args ?? {},
575
- }), provideAllResources, Effect.flatMap(enqueueNormal), Effect.catchCause(cause => crashWith(cause, Option.none())))));
576
- // NOTE: queue-drain-fiber-local state. Kept as plain closure
577
- // variables instead of `Ref`s because nothing else reads or writes
578
- // them concurrently, and JS's single-threaded model already orders
579
- // writes against subsequent reads. `currentMessage` is read by the
580
- // crash handler, which runs inside the same `forever` fiber via
581
- // `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.
582
587
  let currentMessage = Option.none();
583
- let burstStartedAt = 0;
588
+ let maybeLastDirtyMessage = Option.none();
584
589
  // NOTE: the DevTools store is installed at most once during boot and
585
590
  // never replaced. Caching it in a closure variable avoids a
586
- // `Ref.get` on every message and on every render-loop tick (the
587
- // store powers `isPausedEffect`).
588
- 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;
589
596
  const dispatchSync = (message) => {
590
597
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
591
- enqueueHighUnsafe(message);
598
+ enqueueMessage(message);
592
599
  };
593
600
  const dispatchAsync = (message) =>
594
601
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
595
- enqueueHigh(message);
602
+ enqueueMessageEffect(message);
596
603
  const dispatch = { dispatchAsync, dispatchSync };
597
- const isRenderPendingRef = yield* SubscriptionRef.make(false);
598
- const maybeLastDirtyMessageRef = yield* Ref.make(Option.none());
599
- const isPausedEffect = Effect.suspend(() => Option.match(maybeDevToolsStore, {
600
- onNone: () => Effect.succeed(false),
601
- onSome: ({ stateRef }) => SubscriptionRef.get(stateRef).pipe(Effect.map(({ isPaused }) => isPaused)),
602
- }));
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.
603
610
  const mountStartBuffer = [];
604
611
  const mountEndBuffer = [];
605
612
  const mountTracker = {
606
613
  started: (name, args) => {
614
+ if (devToolsStore === null) {
615
+ return;
616
+ }
607
617
  mountStartBuffer.push(args === undefined ? { name } : { name, args });
608
618
  },
609
619
  ended: (name, args) => {
620
+ if (devToolsStore === null) {
621
+ return;
622
+ }
610
623
  mountEndBuffer.push(args === undefined ? { name } : { name, args });
611
624
  },
612
625
  };
@@ -617,8 +630,38 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
617
630
  mountEndBuffer.length = 0;
618
631
  return { starts, ends };
619
632
  };
620
- const processMessage = (message) => Effect.gen(function* () {
621
- 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;
622
665
  const [[nextModelRaw, commands], maybeUpdateDuration] = measureSlowPhase(resolvedSlowUpdate, () => update(currentModel, message));
623
666
  const nextModel = maybeFreezeModel(nextModelRaw);
624
667
  reportSlowPhase(resolvedSlowUpdate, maybeUpdateDuration, (durationMs, thresholdMs) => ({
@@ -630,46 +673,142 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
630
673
  thresholdMs,
631
674
  }));
632
675
  if (currentModel !== nextModel) {
633
- yield* Ref.set(modelRef, nextModel);
634
- yield* SubscriptionRef.set(isRenderPendingRef, true);
635
- yield* Ref.set(maybeLastDirtyMessageRef, Option.some(message));
676
+ liveModel = nextModel;
677
+ maybeLastDirtyMessage = Option.some(message);
636
678
  PubSub.publishUnsafe(modelPubSub, nextModel);
637
- yield* schedulePreserveModel(nextModel);
679
+ if (import.meta.hot) {
680
+ Effect.runSync(schedulePreserveModel(nextModel));
681
+ }
682
+ scheduleRenderFrame();
638
683
  }
639
684
  if (!Array.isReadonlyArrayEmpty(commands)) {
640
- yield* Effect.forEach(
641
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
642
- commands, command => Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
643
- attributes: command.args ?? {},
644
- }), 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
+ }
645
690
  }
646
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
647
- const tag = message._tag;
648
- const isModelChanged = currentModel !== nextModel;
649
- const isExcludedFromHistory = excludeFromHistoryTags.has(tag);
650
- if (Option.isSome(maybeDevToolsStore)) {
651
- const store = maybeDevToolsStore.value;
652
- if (!isExcludedFromHistory) {
653
- 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(
654
705
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
655
706
  message, currentModel, nextModel, Array.map(
656
707
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
657
- commands, toCommandRecord), isModelChanged);
708
+ commands, toCommandRecord), isModelChanged));
658
709
  }
659
710
  else if (isModelChanged) {
660
- yield* store.updateLatestModel(nextModel);
711
+ Effect.runFork(store.updateLatestModel(nextModel));
661
712
  }
662
713
  }
663
- });
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
+ };
664
802
  // NOTE: `dispatchService` defaults to the live dispatch but is
665
803
  // overridable so the DevTools jumpTo render path can pass
666
804
  // `noOpDispatch`. Mount Effects forked during a replay render still
667
805
  // execute (so the rendered DOM looks correct: positioning,
668
806
  // observer attachment, library setup), but their result Messages
669
- // reach a no-op dispatchSync and never enter the runtime queue.
807
+ // reach a no-op dispatchSync and are never processed.
670
808
  // This prevents mount-derived Messages from polluting history when
671
809
  // the user is just inspecting past state.
672
810
  const render = (model, message, dispatchService = dispatch, renderMode = 'Live') => Effect.gen(function* () {
811
+ isRenderingFrame = true;
673
812
  const runtimeContext = yield* Effect.context();
674
813
  const maybeLiveRender = Option.liftPredicate(renderMode, mode => mode === 'Live');
675
814
  if (renderMode === 'Replay') {
@@ -695,9 +834,9 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
695
834
  durationMs,
696
835
  thresholdMs,
697
836
  }));
698
- const maybeCurrentVNode = yield* Ref.get(maybeCurrentVNodeRef);
837
+ const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
699
838
  const [patchedVNode, maybePatchDuration] = yield* Effect.sync(() => measureSlowPhase(maybeLiveSlowPatch, () => __patchVNode(maybeCurrentVNode, nextVNode, container, boundaryRegistry.dedupeSeen)));
700
- yield* Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode));
839
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
701
840
  reportSlowPhase(maybeLiveSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
702
841
  _tag: 'Patch',
703
842
  model,
@@ -711,7 +850,11 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
711
850
  if (import.meta.hot) {
712
851
  yield* Effect.sync(() => duplicateIdScanner?.schedule(patchedVNode.elm));
713
852
  }
714
- }).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));
715
858
  const isInIframe = window.self !== window.top;
716
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 => ({
717
860
  position: config.position ?? DEFAULT_DEV_TOOLS_POSITION,
@@ -731,34 +874,28 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
731
874
  // direct lookup that reflects the real live state at the moment
732
875
  // the entry was recorded.
733
876
  const isExcludingMessages = excludeFromHistoryTags.size > 0;
734
- const devToolsStore = yield* createDevToolsStore({
877
+ const store = yield* createDevToolsStore({
735
878
  /* eslint-disable @typescript-eslint/consistent-type-assertions */
736
879
  replay: (model, message) => {
737
880
  const [updatedModel] = update(model, message);
738
881
  return maybeFreezeModel(updatedModel);
739
882
  },
740
883
  /* eslint-enable @typescript-eslint/consistent-type-assertions */
741
- // NOTE: clears the dirty bit on the jumpTo render so the
742
- // renderLoop's Stream.changes sees the next dispatch as a real
743
- // false-to-true transition rather than a deduped no-op. Passes
744
- // `noOpDispatch` so mount Effects forked during the replay
745
- // render dispatch their result Messages into a no-op (instead
746
- // of enqueueing them as new history entries). Also discards
747
- // mount events fired during the render so they don't get
748
- // 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.
749
889
  render: model => Effect.gen(function* () {
750
- yield* SubscriptionRef.set(isRenderPendingRef, false);
751
890
  yield* render(
752
891
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
753
892
  model, Option.none(), noOpDispatch, 'Replay');
754
893
  drainMountEvents();
755
894
  }),
756
- // NOTE: `resume` calls this to wake the renderLoop after a
757
- // jumpTo render attached DOM listeners to `noOpDispatch`. The
758
- // false-to-true transition triggers one tick on the next
759
- // animation frame, which renders the live model with live
760
- // dispatch and rebinds listeners.
761
- 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()),
762
899
  }, {
763
900
  ...(devToolsKeyframeInterval !== undefined && {
764
901
  keyframeInterval: devToolsKeyframeInterval,
@@ -772,34 +909,24 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
772
909
  // Spread last so it wins over `keyframeInterval` above.
773
910
  ...(isExcludingMessages && { keyframeInterval: 1 }),
774
911
  });
775
- maybeDevToolsStore = Option.some(devToolsStore);
776
- // The init render runs below; capture the events it produces. We
777
- // record init AFTER that render so the buffer reflects the mounts
778
- // 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.
779
915
  yield* Option.match(maybeOverlay, {
780
916
  onNone: () => Effect.void,
781
- onSome: overlay => overlay(devToolsStore, position, mode, maybeBanner),
917
+ onSome: overlay => overlay(store, position, mode, maybeBanner),
782
918
  });
783
919
  if (import.meta.hot) {
784
920
  const maybeMessageSchema = devTools !== undefined && devTools !== false
785
921
  ? Option.fromNullishOr(devTools.Message)
786
922
  : Option.none();
787
- yield* startWebSocketBridge(devToolsStore, import.meta.hot,
923
+ yield* startWebSocketBridge(store, import.meta.hot,
788
924
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
789
- message => enqueueHigh(message),
925
+ message => enqueueMessageEffect(message),
790
926
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
791
927
  maybeMessageSchema);
792
928
  }
793
929
  }
794
- // NOTE: a fast-failing init Command (a `resources` Layer that
795
- // throws synchronously) can render the crash view before this
796
- // point. Rendering the init view would paint over it, so a crashed
797
- // runtime suspends here instead, exactly like the failing-init-
798
- // render path below.
799
- const isCrashedBeforeInitRender = yield* Ref.get(isCrashedRef);
800
- if (isCrashedBeforeInitRender) {
801
- return yield* Effect.never;
802
- }
803
930
  const initRenderExit = yield* Effect.exit(render(initModel, Option.none()));
804
931
  if (Exit.isFailure(initRenderExit)) {
805
932
  yield* crashWith(initRenderExit.cause, Option.none());
@@ -812,43 +939,105 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
812
939
  yield* restorePreservedScrollPosition(runtimeId);
813
940
  }
814
941
  const initMountEvents = drainMountEvents();
815
- yield* Option.match(maybeDevToolsStore, {
816
- onNone: () => Effect.void,
817
- onSome: store => store.recordInit(initModel, Array.map(initCommands, toCommandRecord), initMountEvents.starts),
818
- });
819
- // 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
820
946
  // Message, so slow render-phase callbacks during high-rate bursts attribute
821
947
  // to the last Message in the frame batch, not the specific one that
822
948
  // pushed the view past threshold. Acceptable for a debug callback;
823
949
  // full attribution would require correlating each message with its
824
950
  // render contribution, which isn't worth the complexity.
825
- const renderLoop = makeRenderLoop({
826
- pendingRef: isRenderPendingRef,
827
- awaitNextFrame,
828
- isPaused: isPausedEffect,
829
- render: Effect.gen(function* () {
830
- // NOTE: a Message that dirtied the model can also be the one
831
- // whose Command crashed the runtime. Without this guard the
832
- // next animation frame would render the live view over the
833
- // crash view.
834
- const isCrashed = yield* Ref.get(isCrashedRef);
835
- if (isCrashed) {
836
- 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));
837
985
  }
838
- const model = yield* Ref.get(modelRef);
839
- const maybeMessage = yield* Ref.get(maybeLastDirtyMessageRef);
840
- yield* render(model, maybeMessage);
841
- const mountEvents = drainMountEvents();
842
- yield* Option.match(maybeDevToolsStore, {
843
- onNone: () => Effect.void,
844
- onSome: store => store.attachRenderedMounts(mountEvents.starts, mountEvents.ends),
845
- });
846
- }),
847
- });
848
- yield* Effect.forkIn(runtimeScope)(renderLoop.pipe(Effect.catchCause(cause => Effect.gen(function* () {
849
- const maybeMessage = yield* Ref.get(maybeLastDirtyMessageRef);
850
- yield* crashWith(cause, maybeMessage);
851
- }))));
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
+ };
852
1041
  // NOTE: reloading on bfcache restore is a page-level decision, so
853
1042
  // only a page-owning runtime that manages the document installs the
854
1043
  // listener. An app started through `embed` carries a host connector
@@ -904,7 +1093,7 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
904
1093
  })));
905
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 =>
906
1095
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
907
- enqueueHigh(message)), provideAllResources, Effect.catchCause(cause => crashWith(cause, Option.none()))));
1096
+ enqueueMessageEffect(message)), provideAllResources, Effect.catchCause(cause => crashWith(cause, Option.none()))));
908
1097
  }), {
909
1098
  concurrency: 'unbounded',
910
1099
  discard: true,
@@ -926,79 +1115,56 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
926
1115
  const release = (value) => Effect.gen(function* () {
927
1116
  yield* config.release(value);
928
1117
  yield* Ref.set(resourceRef, Option.none());
929
- yield* enqueueHigh(config.onReleased());
1118
+ yield* enqueueMessageEffect(config.onReleased());
930
1119
  }).pipe(Effect.catchCause(() => Effect.void));
931
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)))));
932
1121
  };
933
1122
  const forkManagedResourceLifecycle = ({ config, ref: resourceRef, }) => Effect.gen(function* () {
934
1123
  const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
935
1124
  const equivalence = Schema.toEquivalence(config.schema);
936
- 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()))));
937
1133
  });
938
1134
  yield* Effect.forEach(managedResourceRefs, forkManagedResourceLifecycle, {
939
1135
  concurrency: 'unbounded',
940
1136
  discard: true,
941
1137
  });
942
- const processWithBudget = (message) => Effect.gen(function* () {
943
- currentMessage = Option.some(message);
944
- yield* processMessage(message);
945
- if (performance.now() - burstStartedAt < FRAME_BUDGET_MS) {
946
- return;
947
- }
948
- yield* yieldToBrowser;
949
- burstStartedAt = performance.now();
950
- });
951
- const processBatch = (batch) => Effect.forEach(orderByPriority(batch), processWithBudget, {
952
- discard: true,
953
- });
954
- // NOTE: Effect 4's `Queue.takeAll` blocks until at least one message
955
- // arrives (it's `takeBetween(self, 1, ∞)`, not a non-blocking
956
- // snapshot). For batching we want "give me whatever is currently in
957
- // the queue, possibly nothing" so we drain via repeated `Queue.poll`
958
- // until it returns `None`.
959
- const pollAvailable = Effect.gen(function* () {
960
- const accumulated = [];
961
- while (true) {
962
- const next = yield* Queue.poll(messageQueue);
963
- if (Option.isNone(next)) {
964
- return accumulated;
965
- }
966
- accumulated.push(next.value);
967
- }
968
- });
969
- const drainQueue = Effect.gen(function* () {
970
- const batch = yield* pollAvailable;
971
- if (Array.isReadonlyArrayEmpty(batch)) {
972
- return;
973
- }
974
- yield* processBatch(batch);
975
- yield* drainQueue;
976
- });
977
- // NOTE: only reset the burst timer when `Queue.take` actually blocked
978
- // (queue was empty). With Command-chained dispatches each forever
979
- // iteration handles a single message, so resetting unconditionally
980
- // would keep the per-iteration cost under FRAME_BUDGET_MS forever
981
- // and the runtime would never yield to the browser. Polling first
982
- // distinguishes "continuing a burst" (poll returns Some) from
983
- // "waking from idle" (poll returns None, take blocks).
984
- yield* pipe(Effect.forever(Effect.gen(function* () {
985
- const maybeFirst = yield* Queue.poll(messageQueue);
986
- const first = yield* Option.match(maybeFirst, {
987
- onNone: () => Effect.gen(function* () {
988
- const message = yield* Queue.take(messageQueue);
989
- burstStartedAt = performance.now();
990
- return message;
991
- }),
992
- onSome: Effect.succeed,
993
- });
994
- const rest = yield* pollAvailable;
995
- yield* processBatch(Array.prepend(rest, first));
996
- yield* drainQueue;
997
- })), Effect.catchCause(cause => crashWith(cause, currentMessage)));
998
- // NOTE: reached only after the drain loop crashed and the crash view
999
- // rendered. Suspending keeps the runtime scope open so the crash view
1000
- // and the DevTools overlay stay up for inspection; interruption
1001
- // (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).
1002
1168
  yield* Effect.never;
1003
1169
  }));
1004
1170
  const start = (hmrModel) => startWith(Option.none(), hmrModel);
@@ -1010,16 +1176,37 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
1010
1176
  });
1011
1177
  return program;
1012
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;
1013
1184
  const currentLocationUrl = () => {
1014
1185
  const { origin, pathname, search } = window.location;
1015
1186
  return `${origin}${pathname}${search}`;
1016
1187
  };
1017
- const upsertHeadElement = (tagName, selector, attributes) => {
1018
- const existing = document.head.querySelector(selector);
1019
- const element = existing ?? document.head.appendChild(document.createElement(tagName));
1020
- Object.entries(attributes).forEach(([key, value]) => {
1021
- element.setAttribute(key, value);
1022
- });
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 };
1023
1210
  };
1024
1211
  const applyDocumentMetadata = (nextDocument, mountedRoot) => {
1025
1212
  if (!mountedRoot || !document.body.contains(mountedRoot)) {
@@ -1030,16 +1217,21 @@ const applyDocumentMetadata = (nextDocument, mountedRoot) => {
1030
1217
  }
1031
1218
  const canonical = nextDocument.canonical ?? currentLocationUrl();
1032
1219
  const ogUrl = nextDocument.ogUrl ?? canonical;
1033
- upsertHeadElement('link', 'link[rel="canonical"]', {
1034
- rel: 'canonical',
1035
- href: canonical,
1036
- });
1037
- upsertHeadElement('meta', 'meta[property="og:url"]', {
1038
- property: 'og:url',
1039
- content: ogUrl,
1040
- });
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
+ }
1041
1233
  };
1042
- const renderCrashView = (context, crash, container, maybeCurrentVNodeRef, manageDocument) => {
1234
+ const renderCrashView = (context, crash, container, vnodeSlot, manageDocument) => {
1043
1235
  console.error('[foldkit] Application crash:', context.error);
1044
1236
  if (crash?.report) {
1045
1237
  try {
@@ -1064,9 +1256,8 @@ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef, manage
1064
1256
  finally {
1065
1257
  clearHtmlRuntime();
1066
1258
  }
1067
- const maybeCurrentVNode = Effect.runSync(Ref.get(maybeCurrentVNodeRef));
1068
- const patchedVNode = __patchVNode(maybeCurrentVNode, crashDocument.body, container);
1069
- Effect.runSync(Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode)));
1259
+ const patchedVNode = __patchVNode(vnodeSlot.maybeCurrentVNode, crashDocument.body, container);
1260
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
1070
1261
  if (manageDocument) {
1071
1262
  applyDocumentMetadata(crashDocument, patchedVNode.elm);
1072
1263
  }
@@ -1082,9 +1273,8 @@ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef, manage
1082
1273
  finally {
1083
1274
  clearHtmlRuntime();
1084
1275
  }
1085
- const maybeCurrentVNode = Effect.runSync(Ref.get(maybeCurrentVNodeRef));
1086
- const patchedVNode = __patchVNode(maybeCurrentVNode, fallbackDocument.body, container);
1087
- Effect.runSync(Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode)));
1276
+ const patchedVNode = __patchVNode(vnodeSlot.maybeCurrentVNode, fallbackDocument.body, container);
1277
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
1088
1278
  if (manageDocument) {
1089
1279
  applyDocumentMetadata(fallbackDocument, patchedVNode.elm);
1090
1280
  }
@@ -1249,9 +1439,12 @@ const PLUGIN_RESPONSE_TIMEOUT_MS = 500;
1249
1439
  // NOTE: scheduling fix for browser performance. Effect needs to defer work
1250
1440
  // onto a future tick of the event loop. The default browser scheduler picks
1251
1441
  // `setTimeout(f, 0)`, but browsers clamp `setTimeout` to a minimum of 4ms.
1252
- // `queueMicrotask` runs on the very next tick (sub-millisecond). Without this
1253
- // override, every dispatched message takes an extra 4-16ms round-trip,
1254
- // 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.
1255
1448
  const microtaskSetImmediate = (callback) => {
1256
1449
  let cancelled = false;
1257
1450
  queueMicrotask(() => {