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.
- package/THIRD-PARTY-NOTICES.md +30 -0
- package/dist/brand/brand.d.ts +25 -0
- package/dist/brand/brand.d.ts.map +1 -0
- package/dist/brand/brand.js +50 -0
- package/dist/brand/index.d.ts +2 -0
- package/dist/brand/index.d.ts.map +1 -0
- package/dist/brand/index.js +1 -0
- package/dist/brand/public.d.ts +2 -0
- package/dist/brand/public.d.ts.map +1 -0
- package/dist/brand/public.js +1 -0
- package/dist/canvas/view.d.ts.map +1 -1
- package/dist/canvas/view.js +1 -1
- package/dist/devTools/protocol.d.ts +1 -1
- package/dist/devTools/protocol.d.ts.map +1 -1
- package/dist/devTools/protocol.js +1 -1
- package/dist/devTools/store.js +2 -2
- package/dist/devTools/webSocketBridge.d.ts +1 -1
- package/dist/devTools/webSocketBridge.js +1 -1
- package/dist/html/index.d.ts +18 -18
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +530 -460
- package/dist/html/lazy.d.ts +1 -1
- package/dist/html/lazy.d.ts.map +1 -1
- package/dist/html/lazy.js +11 -2
- package/dist/propsModule.d.ts +1 -1
- package/dist/propsModule.d.ts.map +1 -1
- package/dist/propsModule.js +6 -1
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +468 -278
- package/dist/snabbdom/attributes.d.ts +4 -0
- package/dist/snabbdom/attributes.d.ts.map +1 -0
- package/dist/snabbdom/attributes.js +63 -0
- package/dist/snabbdom/class.d.ts +4 -0
- package/dist/snabbdom/class.d.ts.map +1 -0
- package/dist/snabbdom/class.js +31 -0
- package/dist/snabbdom/dataset.d.ts +4 -0
- package/dist/snabbdom/dataset.d.ts.map +1 -0
- package/dist/snabbdom/dataset.js +42 -0
- package/dist/snabbdom/eventlisteners.d.ts +11 -0
- package/dist/snabbdom/eventlisteners.d.ts.map +1 -0
- package/dist/snabbdom/eventlisteners.js +85 -0
- package/dist/snabbdom/h.d.ts +11 -0
- package/dist/snabbdom/h.d.ts.map +1 -0
- package/dist/snabbdom/h.js +61 -0
- package/dist/snabbdom/hooks.d.ts +24 -0
- package/dist/snabbdom/hooks.d.ts.map +1 -0
- package/dist/snabbdom/hooks.js +1 -0
- package/dist/snabbdom/htmldomapi.d.ts +34 -0
- package/dist/snabbdom/htmldomapi.d.ts.map +1 -0
- package/dist/snabbdom/htmldomapi.js +111 -0
- package/dist/snabbdom/index.d.ts +15 -0
- package/dist/snabbdom/index.d.ts.map +1 -0
- package/dist/snabbdom/index.js +9 -0
- package/dist/snabbdom/init.d.ts +14 -0
- package/dist/snabbdom/init.d.ts.map +1 -0
- package/dist/snabbdom/init.js +486 -0
- package/dist/snabbdom/is.d.ts +3 -0
- package/dist/snabbdom/is.d.ts.map +1 -0
- package/dist/snabbdom/is.js +7 -0
- package/dist/snabbdom/module.d.ts +11 -0
- package/dist/snabbdom/module.d.ts.map +1 -0
- package/dist/snabbdom/module.js +1 -0
- package/dist/snabbdom/props.d.ts +2 -0
- package/dist/snabbdom/props.d.ts.map +1 -0
- package/dist/snabbdom/props.js +1 -0
- package/dist/snabbdom/style.d.ts +9 -0
- package/dist/snabbdom/style.d.ts.map +1 -0
- package/dist/snabbdom/style.js +123 -0
- package/dist/snabbdom/tovnode.d.ts +4 -0
- package/dist/snabbdom/tovnode.d.ts.map +1 -0
- package/dist/snabbdom/tovnode.js +69 -0
- package/dist/snabbdom/vnode.d.ts +46 -0
- package/dist/snabbdom/vnode.d.ts.map +1 -0
- package/dist/snabbdom/vnode.js +13 -0
- package/dist/vdom.d.ts +2 -2
- package/dist/vdom.d.ts.map +1 -1
- package/dist/vdom.js +1 -1
- package/package.json +7 -5
- package/dist/runtime/messagePriority.d.ts +0 -17
- package/dist/runtime/messagePriority.d.ts.map +0 -1
- package/dist/runtime/messagePriority.js +0 -33
- package/dist/runtime/renderLoop.d.ts +0 -27
- package/dist/runtime/renderLoop.d.ts.map +0 -1
- package/dist/runtime/renderLoop.js +0 -25
package/dist/runtime/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Array, Cause, Context, Duration, Effect, Exit, Fiber, Function, Layer, Match, Option, Predicate, PubSub,
|
|
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 (
|
|
364
|
-
// ManagedResource lifecycles) and every Command fiber forks
|
|
365
|
-
// runtime scope, so interrupting the runtime fiber (what
|
|
366
|
-
// does) interrupts them all and runs their finalizers. A
|
|
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:
|
|
500
|
-
//
|
|
501
|
-
// so
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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(
|
|
528
|
+
yield* Effect.acquireRelease(Effect.sync(() => addNavigationEventListeners(enqueueMessage, routingConfig)), removeNavigationEventListeners => Effect.sync(() => removeNavigationEventListeners()));
|
|
525
529
|
}
|
|
526
|
-
|
|
527
|
-
|
|
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 =
|
|
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
|
-
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
// Command's Effect and a Subscription's
|
|
557
|
-
// `never` error channel, so a cause
|
|
558
|
-
// `resources` Layer build failure or an
|
|
559
|
-
// unrecoverable). Each
|
|
560
|
-
// a failure surfaces as the crash view instead of dying
|
|
561
|
-
// and leaving the DOM frozen at the last successful
|
|
562
|
-
// first crash wins: concurrent Command fibers can fail
|
|
563
|
-
// broken Layer, and only one should report and render.
|
|
564
|
-
const crashWith = (cause, maybeMessage) => Effect.
|
|
565
|
-
|
|
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
|
-
|
|
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,
|
|
581
|
+
renderCrashView({ error, model, message: maybeMessage }, crash, container, vnodeSlot, manageDocument);
|
|
573
582
|
});
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
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
|
|
590
|
-
// store powers `
|
|
591
|
-
|
|
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
|
-
|
|
598
|
+
enqueueMessage(message);
|
|
595
599
|
};
|
|
596
600
|
const dispatchAsync = (message) =>
|
|
597
601
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
598
|
-
|
|
602
|
+
enqueueMessageEffect(message);
|
|
599
603
|
const dispatch = { dispatchAsync, dispatchSync };
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
624
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
yield* Ref.set(maybeLastDirtyMessageRef, Option.some(message));
|
|
676
|
+
liveModel = nextModel;
|
|
677
|
+
maybeLastDirtyMessage = Option.some(message);
|
|
639
678
|
PubSub.publishUnsafe(modelPubSub, nextModel);
|
|
640
|
-
|
|
679
|
+
if (import.meta.hot) {
|
|
680
|
+
Effect.runSync(schedulePreserveModel(nextModel));
|
|
681
|
+
}
|
|
682
|
+
scheduleRenderFrame();
|
|
641
683
|
}
|
|
642
684
|
if (!Array.isReadonlyArrayEmpty(commands)) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
837
|
+
const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
|
|
702
838
|
const [patchedVNode, maybePatchDuration] = yield* Effect.sync(() => measureSlowPhase(maybeLiveSlowPatch, () => __patchVNode(maybeCurrentVNode, nextVNode, container, boundaryRegistry.dedupeSeen)));
|
|
703
|
-
|
|
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(() =>
|
|
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
|
|
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:
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
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
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
|
|
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
|
-
|
|
779
|
-
//
|
|
780
|
-
//
|
|
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(
|
|
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(
|
|
923
|
+
yield* startWebSocketBridge(store, import.meta.hot,
|
|
791
924
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
792
|
-
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
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*
|
|
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(
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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,
|
|
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
|
|
1071
|
-
|
|
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
|
|
1089
|
-
|
|
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).
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
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(() => {
|