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.
- 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/command/index.d.ts +6 -1
- package/dist/command/index.d.ts.map +1 -1
- package/dist/command/interruptible/index.d.ts +2 -0
- package/dist/command/interruptible/index.d.ts.map +1 -0
- package/dist/command/interruptible/index.js +1 -0
- package/dist/command/interruptible/interruptible.d.ts +177 -0
- package/dist/command/interruptible/interruptible.d.ts.map +1 -0
- package/dist/command/interruptible/interruptible.js +155 -0
- package/dist/command/interruptible/public.d.ts +3 -0
- package/dist/command/interruptible/public.d.ts.map +1 -0
- package/dist/command/interruptible/public.js +1 -0
- package/dist/command/public.d.ts +1 -0
- package/dist/command/public.d.ts.map +1 -1
- package/dist/command/public.js +1 -0
- 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 +472 -279
- 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/test/apps/uploads.d.ts +81 -0
- package/dist/test/apps/uploads.d.ts.map +1 -0
- package/dist/test/apps/uploads.js +102 -0
- package/dist/test/internal.d.ts +16 -2
- package/dist/test/internal.d.ts.map +1 -1
- package/dist/test/internal.js +20 -4
- 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,5 +1,6 @@
|
|
|
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
|
+
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 (
|
|
363
|
-
// ManagedResource lifecycles) and every Command fiber forks
|
|
364
|
-
// runtime scope, so interrupting the runtime fiber (what
|
|
365
|
-
// does) interrupts them all and runs their finalizers. A
|
|
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
|
-
|
|
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:
|
|
497
|
-
//
|
|
498
|
-
// so
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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(
|
|
528
|
+
yield* Effect.acquireRelease(Effect.sync(() => addNavigationEventListeners(enqueueMessage, routingConfig)), removeNavigationEventListeners => Effect.sync(() => removeNavigationEventListeners()));
|
|
522
529
|
}
|
|
523
|
-
|
|
524
|
-
|
|
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 =
|
|
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
|
-
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
// Command's Effect and a Subscription's
|
|
554
|
-
// `never` error channel, so a cause
|
|
555
|
-
// `resources` Layer build failure or an
|
|
556
|
-
// unrecoverable). Each
|
|
557
|
-
// a failure surfaces as the crash view instead of dying
|
|
558
|
-
// and leaving the DOM frozen at the last successful
|
|
559
|
-
// first crash wins: concurrent Command fibers can fail
|
|
560
|
-
// broken Layer, and only one should report and render.
|
|
561
|
-
const crashWith = (cause, maybeMessage) => Effect.
|
|
562
|
-
|
|
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
|
-
|
|
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,
|
|
581
|
+
renderCrashView({ error, model, message: maybeMessage }, crash, container, vnodeSlot, manageDocument);
|
|
570
582
|
});
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
|
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
|
|
587
|
-
// store powers `
|
|
588
|
-
|
|
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
|
-
|
|
598
|
+
enqueueMessage(message);
|
|
592
599
|
};
|
|
593
600
|
const dispatchAsync = (message) =>
|
|
594
601
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
595
|
-
|
|
602
|
+
enqueueMessageEffect(message);
|
|
596
603
|
const dispatch = { dispatchAsync, dispatchSync };
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
yield* Ref.set(maybeLastDirtyMessageRef, Option.some(message));
|
|
676
|
+
liveModel = nextModel;
|
|
677
|
+
maybeLastDirtyMessage = Option.some(message);
|
|
636
678
|
PubSub.publishUnsafe(modelPubSub, nextModel);
|
|
637
|
-
|
|
679
|
+
if (import.meta.hot) {
|
|
680
|
+
Effect.runSync(schedulePreserveModel(nextModel));
|
|
681
|
+
}
|
|
682
|
+
scheduleRenderFrame();
|
|
638
683
|
}
|
|
639
684
|
if (!Array.isReadonlyArrayEmpty(commands)) {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
837
|
+
const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
|
|
699
838
|
const [patchedVNode, maybePatchDuration] = yield* Effect.sync(() => measureSlowPhase(maybeLiveSlowPatch, () => __patchVNode(maybeCurrentVNode, nextVNode, container, boundaryRegistry.dedupeSeen)));
|
|
700
|
-
|
|
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(() =>
|
|
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
|
|
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:
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
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
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
|
|
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
|
-
|
|
776
|
-
//
|
|
777
|
-
//
|
|
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(
|
|
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(
|
|
923
|
+
yield* startWebSocketBridge(store, import.meta.hot,
|
|
788
924
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
789
|
-
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
|
|
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*
|
|
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(
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
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
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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,
|
|
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
|
|
1068
|
-
|
|
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
|
|
1086
|
-
|
|
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).
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
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(() => {
|