foldkit 0.137.0 → 0.139.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.
@@ -6,6 +6,7 @@ import { startWebSocketBridge } from '../devTools/webSocketBridge.js';
6
6
  import { __beginRender as beginHtmlRender, __beginReplayRender as beginReplayHtmlRender, __clearRuntime as clearHtmlRuntime, __createBoundaryRegistry as createHtmlBoundaryRegistry, __endReplayRender as endReplayHtmlRender, __htmlBuilder as htmlBuilderFor, __setRuntime as setHtmlRuntime, } from '../html/index.js';
7
7
  import { MountTracker } from '../mount/index.js';
8
8
  import { __CurrentPortChannels, __makeInboundChannel, } from '../port/index.js';
9
+ import { RenderCommit, createCommitNotifier } from '../render/commit.js';
9
10
  import { fromString as urlFromString } from '../url/index.js';
10
11
  import { __patchVNode } from '../vdom.js';
11
12
  import { addBfcacheRestoreListener, addNavigationEventListeners, } from './browserListeners.js';
@@ -14,9 +15,15 @@ import { deepFreeze } from './deepFreeze.js';
14
15
  import { PreserveModelMessage, RequestModelMessage, RestoreModelMessage, } from './hmrProtocol.js';
15
16
  import { preserveScrollPosition, restorePreservedScrollPosition, } from './hmrScroll.js';
16
17
  import { makePreserveScheduler } from './preserveScheduler.js';
18
+ import { __decideViewTransition, __resolveStartViewTransition, __silenceViewTransitionRejections, } from './viewTransition.js';
17
19
  const toCommandRecord = (command) => command.args !== undefined
18
20
  ? { name: command.name, args: command.args }
19
21
  : { name: command.name };
22
+ let registeredDevToolsOverlay;
23
+ /** Registers the overlay supplied by the Foldkit Vite plugin. */
24
+ export const __setDevToolsOverlay = (overlay) => {
25
+ registeredDevToolsOverlay = overlay;
26
+ };
20
27
  const DEFAULT_DEV_TOOLS_SHOW = 'Development';
21
28
  const DEFAULT_DEV_TOOLS_POSITION = 'BottomRight';
22
29
  const DEFAULT_DEV_TOOLS_MODE = 'TimeTravel';
@@ -308,7 +315,7 @@ const validatePorts = (ports) => {
308
315
  });
309
316
  };
310
317
  const runtimeInternals = new WeakMap();
311
- const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, manageDocument, subscriptions, container, routing: routingConfig, crash, slow, freezeModel, preserveScroll, resources, managedResources, devTools, }) => {
318
+ const makeRuntime = ({ ports, Model, flags: maybeResolveFlags, init, update, view, manageDocument, subscriptions, container, routing: routingConfig, crash, slow, viewTransition, freezeModel, preserveScroll, resources, managedResources, devTools, }) => {
312
319
  const isSlowVisible = (show) => Match.value(show).pipe(Match.when('Always', () => true), Match.when('Development', () => !!import.meta.hot), Match.exhaustive);
313
320
  const htmlBuilder = htmlBuilderFor();
314
321
  const resolvedSlow = __resolveSlowConfig(slow, isSlowVisible);
@@ -316,6 +323,15 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
316
323
  const resolvedSlowUpdate = Option.flatMap(resolvedSlow, ({ update }) => update);
317
324
  const resolvedSlowPatch = Option.flatMap(resolvedSlow, ({ patch }) => patch);
318
325
  const resolvedSlowSubscriptionDependencies = Option.flatMap(resolvedSlow, ({ subscriptionDependencies }) => subscriptionDependencies);
326
+ // NOTE: detection sits inside the flatMap so it runs only for applications
327
+ // that configured the option. Resolved eagerly it would read
328
+ // `document.startViewTransition` and call `window.matchMedia` during
329
+ // `makeRuntime`, which otherwise touches no DOM global at construction.
330
+ const maybeResolvedViewTransition = pipe(Option.fromNullishOr(viewTransition), Option.flatMap(decide => Option.map(__resolveStartViewTransition(), startViewTransition => ({
331
+ decide,
332
+ startViewTransition,
333
+ reducedMotionQuery: window.matchMedia('(prefers-reduced-motion: reduce)'),
334
+ }))));
319
335
  const isFreezeModelActive = freezeModel !== false && !!import.meta.hot;
320
336
  const isPreserveScrollActive = preserveScroll !== false && manageDocument && !!import.meta.hot;
321
337
  const duplicateIdScanner = import.meta.hot
@@ -338,836 +354,1043 @@ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, ma
338
354
  validatePorts(ports);
339
355
  }
340
356
  const runtimeId = container?.id ?? '';
341
- const startWith = (maybeConnector, hmrModel) => Effect.scoped(Effect.gen(function* () {
342
- if (runtimeId === '') {
343
- return yield* Effect.die(new Error('[foldkit] Runtime container must have an `id` for HMR model preservation. ' +
344
- 'Set `container.id = "app"` (or any unique string) before passing it to makeApplication or makeElement.'));
345
- }
346
- // NOTE: every perpetual fiber (for example, Subscription streams
347
- // and ManagedResource lifecycles) and every Command fiber forks
348
- // into the runtime scope, so interrupting the runtime fiber (what
349
- // dispose does) interrupts them all and runs their finalizers. A
350
- // detached fork would outlive the runtime.
351
- const runtimeScope = yield* Effect.scope;
352
- // NOTE: `Effect.provide(effect, layer)` builds the Layer into a
353
- // scope that closes when the provided effect ends, so providing the
354
- // Layer per Command would construct and tear down every resource on
355
- // each invocation. Building once into `runtimeScope` through a
356
- // cached Effect is what makes `resources` long-lived: the first
357
- // Command or Subscription that runs triggers construction, every
358
- // later one shares the same built services, and release happens at
359
- // runtime teardown. The build is uninterruptible because
360
- // `Effect.cached` caches whatever Exit the first run produces:
361
- // dispose racing an in-flight build would otherwise cache an
362
- // interrupt, which every waiter would then surface as a crash.
363
- const maybeAcquireResourceContext = yield* Option.match(Option.fromNullishOr(resources), {
364
- onNone: () => Effect.succeed(Option.none()),
365
- onSome: resourceLayer => Effect.map(Effect.cached(Effect.uninterruptible(Layer.buildWithScope(resourceLayer, runtimeScope))), Option.some),
366
- });
367
- const maybePortChannels = pipe(Option.fromNullishOr(ports), Option.map(portsConfig => makePortChannels(portsConfig, maybeConnector)));
368
- yield* Option.match(Option.all({
369
- connector: maybeConnector,
370
- portChannels: maybePortChannels,
371
- }), {
372
- onNone: () => Effect.void,
373
- onSome: ({ connector, portChannels }) => Effect.acquireRelease(Effect.sync(() => connector.bind(portChannels.deliverInbound)), () => Effect.sync(() => connector.unbind())),
374
- });
375
- // NOTE: One boundary registry per runtime instance, shared
376
- // across renders so Submodel wrap descriptors registered by
377
- // h.submodel persist between renders. The render function calls
378
- // `beginHtmlRender` at the start of each pass; wraps for
379
- // unmounted Submodels (e.g. an entry removed from a list) are
380
- // dropped from the registry via snabbdom destroy hooks attached
381
- // by `h.submodel` to each child vnode.
382
- const boundaryRegistry = createHtmlBoundaryRegistry();
383
- const managedResourceEntries = managedResources
384
- ? /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
385
- Record.toEntries(managedResources)
386
- : [];
387
- const managedResourceRefs = yield* Effect.forEach(managedResourceEntries, ([_key, config]) => Ref.make(Option.none()).pipe(Effect.map(ref => ({ config, ref }))));
388
- const mergeResourceIntoLayer = (layer, { config, ref }) => Layer.merge(layer, Layer.succeed(
389
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
390
- config.resource._tag, ref));
391
- const maybeManagedResourceLayer = Array.match(managedResourceRefs, {
392
- onEmpty: () => Option.none(),
393
- onNonEmpty: refs => Option.some(Array.reduce(refs,
394
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
395
- Layer.empty, mergeResourceIntoLayer)),
396
- });
397
- const interruptRegistry = __makeInterruptRegistry();
398
- const provideAllResources = (effect) => {
399
- const withResources = Option.match(maybeAcquireResourceContext, {
400
- onNone: () => effect,
401
- onSome: acquireResourceContext => Effect.flatMap(acquireResourceContext, resourceContext => Effect.provideContext(effect, resourceContext)),
357
+ const startWith = (maybeConnector, hmrModel) => {
358
+ // NOTE: one notifier per runtime, provided across the whole runtime
359
+ // Effect so Commands, Subscriptions, and Mount-forked Effects all resolve
360
+ // the same signal. A commit in one embedded application must never wake a
361
+ // `Render.afterCommit` awaiting inside another.
362
+ const commitNotifier = createCommitNotifier();
363
+ return Effect.scoped(Effect.gen(function* () {
364
+ if (runtimeId === '') {
365
+ return yield* Effect.die(new Error('[foldkit] Runtime container must have an `id` for HMR model preservation. ' +
366
+ 'Set `container.id = "app"` (or any unique string) before passing it to makeApplication or makeElement.'));
367
+ }
368
+ // NOTE: every perpetual fiber (for example, Subscription streams
369
+ // and ManagedResource lifecycles) and every Command fiber forks
370
+ // into the runtime scope, so interrupting the runtime fiber (what
371
+ // dispose does) interrupts them all and runs their finalizers. A
372
+ // detached fork would outlive the runtime.
373
+ const runtimeScope = yield* Effect.scope;
374
+ // NOTE: `Effect.provide(effect, layer)` builds the Layer into a
375
+ // scope that closes when the provided effect ends, so providing the
376
+ // Layer per Command would construct and tear down every resource on
377
+ // each invocation. Building once into `runtimeScope` through a
378
+ // cached Effect is what makes `resources` long-lived: the first
379
+ // Command or Subscription that runs triggers construction, every
380
+ // later one shares the same built services, and release happens at
381
+ // runtime teardown. The build is uninterruptible because
382
+ // `Effect.cached` caches whatever Exit the first run produces:
383
+ // dispose racing an in-flight build would otherwise cache an
384
+ // interrupt, which every waiter would then surface as a crash.
385
+ const maybeAcquireResourceContext = yield* Option.match(Option.fromNullishOr(resources), {
386
+ onNone: () => Effect.succeed(Option.none()),
387
+ onSome: resourceLayer => Effect.map(Effect.cached(Effect.uninterruptible(Layer.buildWithScope(resourceLayer, runtimeScope))), Option.some),
402
388
  });
403
- const withManagedResources = Option.match(maybeManagedResourceLayer, {
389
+ const maybePortChannels = pipe(Option.fromNullishOr(ports), Option.map(portsConfig => makePortChannels(portsConfig, maybeConnector)));
390
+ yield* Option.match(Option.all({
391
+ connector: maybeConnector,
392
+ portChannels: maybePortChannels,
393
+ }), {
394
+ onNone: () => Effect.void,
395
+ onSome: ({ connector, portChannels }) => Effect.acquireRelease(Effect.sync(() => connector.bind(portChannels.deliverInbound)), () => Effect.sync(() => connector.unbind())),
396
+ });
397
+ // NOTE: One boundary registry per runtime instance, shared
398
+ // across renders so Submodel wrap descriptors registered by
399
+ // h.submodel persist between renders. The render function calls
400
+ // `beginHtmlRender` at the start of each pass; wraps for
401
+ // unmounted Submodels (e.g. an entry removed from a list) are
402
+ // dropped from the registry via snabbdom destroy hooks attached
403
+ // by `h.submodel` to each child vnode.
404
+ const boundaryRegistry = createHtmlBoundaryRegistry();
405
+ const managedResourceEntries = managedResources
406
+ ? /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
407
+ Record.toEntries(managedResources)
408
+ : [];
409
+ const managedResourceRefs = yield* Effect.forEach(managedResourceEntries, ([_key, config]) => Ref.make(Option.none()).pipe(Effect.map(ref => ({ config, ref }))));
410
+ const mergeResourceIntoLayer = (layer, { config, ref }) => Layer.merge(layer, Layer.succeed(
411
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
412
+ config.resource._tag, ref));
413
+ const maybeManagedResourceLayer = Array.match(managedResourceRefs, {
414
+ onEmpty: () => Option.none(),
415
+ onNonEmpty: refs => Option.some(Array.reduce(refs,
404
416
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
405
- onNone: () => withResources,
406
- onSome: managedLayer =>
417
+ Layer.empty, mergeResourceIntoLayer)),
418
+ });
419
+ const interruptRegistry = __makeInterruptRegistry();
420
+ const provideAllResources = (effect) => {
421
+ const withResources = Option.match(maybeAcquireResourceContext, {
422
+ onNone: () => effect,
423
+ onSome: acquireResourceContext => Effect.flatMap(acquireResourceContext, resourceContext => Effect.provideContext(effect, resourceContext)),
424
+ });
425
+ const withManagedResources = Option.match(maybeManagedResourceLayer, {
426
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
427
+ onNone: () => withResources,
428
+ onSome: managedLayer =>
429
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
430
+ Effect.provide(withResources, managedLayer),
431
+ });
432
+ const withPortChannels = Option.match(maybePortChannels, {
433
+ onNone: () => withManagedResources,
434
+ onSome: portChannels => Effect.provideService(withManagedResources, __CurrentPortChannels, portChannels.channels),
435
+ });
436
+ return Effect.provideService(withPortChannels, __CurrentInterruptRegistry, interruptRegistry);
437
+ };
438
+ // NOTE: flags run through the same cached build that Commands and
439
+ // Subscriptions use, rather than being handed the Layer again, so a
440
+ // service needed both at startup and by a Command is constructed
441
+ // once. An app without flags never reaches it, which keeps the Layer
442
+ // lazy when the first thing that needs it is a Command.
443
+ //
444
+ // NOTE: a Layer that fails to build is not fatal here. Flags resolve
445
+ // before `init`, so there is no Model for a crash view to render
446
+ // against and a failure escaping this point kills the app with a
447
+ // blank container. Running flags against an empty context instead
448
+ // lets an app whose flags never touch the Layer boot as it did
449
+ // before flags could consume `resources`: the cached failure then
450
+ // surfaces at the first Command or Subscription, where `crashWith`
451
+ // does render the crash view. Flags that do need the Layer still
452
+ // fail here, and both causes are reported: the `Service not found`
453
+ // defect the empty context produced is useless on its own, and the
454
+ // build failure that explains it would be lost if it replaced the
455
+ // flags cause outright. Combining them also keeps a flags Effect
456
+ // that fails for its own unrelated reason visible instead of
457
+ // attributing its defect to the Layer. Interrupts propagate
458
+ // untouched on both sides, because dispose racing either the build
459
+ // or the flags run is not a failure to recover from, and
460
+ // `Effect.catchCause` hands the handler interrupt causes too.
461
+ const provideResources = (effect) => Option.match(maybeAcquireResourceContext, {
407
462
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
408
- Effect.provide(withResources, managedLayer),
463
+ onNone: () => effect,
464
+ onSome: acquireResourceContext => Effect.matchCauseEffect(acquireResourceContext, {
465
+ onFailure: buildCause => Cause.hasInterruptsOnly(buildCause)
466
+ ? Effect.failCause(buildCause)
467
+ : Effect.catchCause(Effect.provideContext(effect,
468
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
469
+ Context.empty()), flagsCause => Cause.hasInterruptsOnly(flagsCause)
470
+ ? Effect.failCause(flagsCause)
471
+ : Effect.failCause(Cause.combine(buildCause, flagsCause))),
472
+ onSuccess: resourceContext => Effect.provideContext(effect, resourceContext),
473
+ }),
409
474
  });
410
- const withPortChannels = Option.match(maybePortChannels, {
411
- onNone: () => withManagedResources,
412
- onSome: portChannels => Effect.provideService(withManagedResources, __CurrentPortChannels, portChannels.channels),
475
+ const resolveFlags = Option.match(maybeResolveFlags, {
476
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
477
+ onNone: () => Effect.succeed(undefined),
478
+ onSome: provideResources,
413
479
  });
414
- return Effect.provideService(withPortChannels, __CurrentInterruptRegistry, interruptRegistry);
415
- };
416
- const flags = yield* resolveFlags;
417
- const ModelJsonCodec = Schema.toCodecJson(
418
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
419
- Model);
420
- const decodeHmrModel = Schema.decodeUnknownExit(ModelJsonCodec);
421
- const encodeHmrModel = Schema.encodeUnknownSync(ModelJsonCodec);
422
- // NOTE: keep `encodeHmrModel` off the dispatch hot path. It walks
423
- // the entire Model graph (O(modelSize) per call) and blocks input
424
- // on large Models. The scheduler defers encoding to a quiet window
425
- // and the `vite:beforeFullReload` flush covers the HMR boundary.
426
- const PRESERVE_DEBOUNCE = Duration.millis(200);
427
- const preserveScheduler = yield* makePreserveScheduler({
428
- onDebounce: model => Effect.sync(() => preserveModel(runtimeId, encodeHmrModel(model), false)),
429
- onFlush: model => Effect.sync(() => preserveModel(runtimeId, encodeHmrModel(model), true)),
430
- }, PRESERVE_DEBOUNCE);
431
- const hot = import.meta.hot;
432
- if (hot) {
433
- yield* Effect.acquireRelease(Effect.sync(() => {
434
- // NOTE: Effect.runSync requires `flush` to have no async
435
- // suspensions. The scheduler is built to satisfy that: flush
436
- // clears pending atomically and runs `onFlush` without
437
- // interrupting the in-flight timer fiber, which keeps the
438
- // whole effect synchronous. If a future change adds an async
439
- // step (interrupt-await, sleep, fork) on this path, Vite may
440
- // race ahead to location.reload() before the encoded model
441
- // reaches the plugin.
442
- const handler = () => {
443
- Effect.runSync(preserveScheduler.flush);
444
- };
445
- hot.on('vite:beforeFullReload', handler);
446
- return handler;
447
- }), handler => Effect.sync(() => hot.off('vite:beforeFullReload', handler)));
448
- yield* Effect.addFinalizer(() => preserveScheduler.cancel);
449
- }
450
- if (hot && isPreserveScrollActive) {
451
- yield* Effect.acquireRelease(Effect.sync(() => {
452
- const handler = () => preserveScrollPosition(runtimeId);
453
- hot.on('vite:beforeFullReload', handler);
454
- return handler;
455
- }), handler => Effect.sync(() => hot.off('vite:beforeFullReload', handler)));
456
- }
457
- const schedulePreserveModel = (model) => hot ? preserveScheduler.schedule(model) : Effect.void;
458
- // NOTE: the dispatch hot path is plain JavaScript. A dispatched
459
- // Message is pushed onto a plain array and drained synchronously on
460
- // the spot, so update runs on the dispatching stack (for example, a
461
- // DOM event handler, a Command fiber completing, or a Subscription
462
- // emit) with no fiber hop in between. The drain guards against
463
- // re-entrancy: a Message dispatched mid-drain (for example, by an
464
- // update triggered from a synchronous Command) is queued and picked
465
- // up by the outer drain loop in arrival order, and a Message
466
- // dispatched while a render frame's patch is on the stack is
467
- // buffered until the frame completes.
468
- let pendingMessages = [];
469
- let isProcessingMessages = false;
470
- let isRenderFrameScheduled = false;
471
- // NOTE: mirrors the old queue's boot behavior: a Message arriving
472
- // before boot completes (for example, a navigation event during an
473
- // async dev-mode boot step, or a boot-forked fiber emitting early)
474
- // is buffered, not processed. Processing against a partially
475
- // initialized runtime would race the init render, DevTools
476
- // recording, and Subscription attachment. The flag flips as the
477
- // last act of boot, which then drains the buffer. enqueueMessage
478
- // checks it directly, not just the drain: dispatch sources go live
479
- // mid-boot, before `drainPendingMessages` is initialized, and
480
- // calling it from a pre-boot dispatch would hit the temporal dead
481
- // zone.
482
- let isBootComplete = false;
483
- // NOTE: mirrors the old queue's post-interrupt behavior: a Message
484
- // dispatched after the runtime scope closed (for example, an
485
- // OnUnmount fired by the dispose teardown patch, or a stale DOM
486
- // handler) is dropped
487
- // instead of updating a disposed runtime. Set by a finalizer
488
- // registered at the end of boot, so it runs before
489
- // earlier-registered teardown (finalizers are LIFO).
490
- let isRuntimeDisposed = false;
491
- // NOTE: the differ fires destroy and insert hooks while `patch` is
492
- // on the stack, and both can dispatch synchronously (for example,
493
- // an OnUnmount dispatch, or a Mount stream's synchronous first
494
- // emission). Draining
495
- // inline would run update, and on a defect the crash renderer,
496
- // against a DOM the outer patch is still mutating. The frame
497
- // buffers such dispatches and drains them after it completes.
498
- let isRenderingFrame = false;
499
- // NOTE: a crash is terminal. The old runtime's drain fiber died on
500
- // the first defect, so nothing was processed after a crash; this
501
- // flag preserves that: the drain stops and later dispatches are
502
- // dropped, so update, Command forks, and DevTools recording all
503
- // stop with the crash view on screen.
504
- let isCrashed = false;
505
- const enqueueMessage = (message) => {
506
- if (isRuntimeDisposed || isCrashed) {
507
- return;
480
+ const ModelJsonCodec = Schema.toCodecJson(
481
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
482
+ Model);
483
+ const decodeHmrModel = Schema.decodeUnknownExit(ModelJsonCodec);
484
+ const encodeHmrModel = Schema.encodeUnknownSync(ModelJsonCodec);
485
+ const currentUrl = Option.fromNullishOr(routingConfig).pipe(Option.flatMap(() => urlFromString(window.location.href)));
486
+ // NOTE: a restored Model skips `init`, so resolving flags on that
487
+ // path would build the `resources` Layer only to discard what it
488
+ // produced. Gating the resolution on the restore decision is what
489
+ // stops a reload from reconnecting whatever the Layer holds. It has
490
+ // to stay ahead of the preserve-scheduler and HMR finalizers: a
491
+ // flags Effect that fails after those are registered tears down more
492
+ // than it used to, and their release defects would bury its cause.
493
+ const runInit = Effect.map(resolveFlags, flags => init(flags, Option.getOrUndefined(currentUrl)));
494
+ const [initModelRaw, initCommands] = yield* hmrModel !== undefined
495
+ ? Exit.match(decodeHmrModel(hmrModel), {
496
+ onFailure: () => runInit,
497
+ onSuccess: restoredModel => Effect.succeed([restoredModel, []]),
498
+ })
499
+ : runInit;
500
+ // NOTE: keep `encodeHmrModel` off the dispatch hot path. It walks
501
+ // the entire Model graph (O(modelSize) per call) and blocks input
502
+ // on large Models. The scheduler defers encoding to a quiet window
503
+ // and the `vite:beforeFullReload` flush covers the HMR boundary.
504
+ const PRESERVE_DEBOUNCE = Duration.millis(200);
505
+ const preserveScheduler = yield* makePreserveScheduler({
506
+ onDebounce: model => Effect.sync(() => preserveModel(runtimeId, encodeHmrModel(model), false)),
507
+ onFlush: model => Effect.sync(() => preserveModel(runtimeId, encodeHmrModel(model), true)),
508
+ }, PRESERVE_DEBOUNCE);
509
+ const hot = import.meta.hot;
510
+ if (hot) {
511
+ yield* Effect.acquireRelease(Effect.sync(() => {
512
+ // NOTE: Effect.runSync requires `flush` to have no async
513
+ // suspensions. The scheduler is built to satisfy that: flush
514
+ // clears pending atomically and runs `onFlush` without
515
+ // interrupting the in-flight timer fiber, which keeps the
516
+ // whole effect synchronous. If a future change adds an async
517
+ // step (interrupt-await, sleep, fork) on this path, Vite may
518
+ // race ahead to location.reload() before the encoded model
519
+ // reaches the plugin.
520
+ const handler = () => {
521
+ Effect.runSync(preserveScheduler.flush);
522
+ };
523
+ hot.on('vite:beforeFullReload', handler);
524
+ return handler;
525
+ }), handler => Effect.sync(() => hot.off('vite:beforeFullReload', handler)));
526
+ yield* Effect.addFinalizer(() => preserveScheduler.cancel);
508
527
  }
509
- pendingMessages.push(message);
510
- if (!isBootComplete || isRenderingFrame) {
511
- return;
528
+ if (hot && isPreserveScrollActive) {
529
+ yield* Effect.acquireRelease(Effect.sync(() => {
530
+ const handler = () => preserveScrollPosition(runtimeId);
531
+ hot.on('vite:beforeFullReload', handler);
532
+ return handler;
533
+ }), handler => Effect.sync(() => hot.off('vite:beforeFullReload', handler)));
512
534
  }
513
- drainPendingMessages();
514
- };
515
- const enqueueMessageEffect = (message) => Effect.sync(() => enqueueMessage(message));
516
- const currentUrl = Option.fromNullishOr(routingConfig).pipe(Option.flatMap(() => urlFromString(window.location.href)));
517
- const [initModelRaw, initCommands] = Predicate.isNotUndefined(hmrModel)
518
- ? Exit.match(decodeHmrModel(hmrModel), {
519
- onFailure: () => init(flags, Option.getOrUndefined(currentUrl)),
520
- onSuccess: (restoredModel) => [restoredModel, []],
521
- })
522
- : init(flags, Option.getOrUndefined(currentUrl));
523
- const initModel = maybeFreezeModel(initModelRaw);
524
- const modelPubSub = yield* PubSub.unbounded();
525
- if (import.meta.hot) {
526
- yield* Effect.addFinalizer(() => Effect.sync(() => duplicateIdScanner?.cancel()));
527
- }
528
- if (routingConfig) {
529
- yield* Effect.acquireRelease(Effect.sync(() => addNavigationEventListeners(enqueueMessage, routingConfig)), removeNavigationEventListeners => Effect.sync(() => removeNavigationEventListeners()));
530
- }
531
- // NOTE: the model and the current vnode are plain closure state.
532
- // The hot path reads and writes them directly; the cold paths that
533
- // run inside Effects (crash rendering, the dispose finalizer, the
534
- // replay render) read the same variables synchronously, so no Ref
535
- // is needed.
536
- let liveModel = initModel;
537
- const vnodeSlot = { maybeCurrentVNode: Option.none() };
538
- // NOTE: registered before any perpetual fiber is forked so it runs
539
- // after they are interrupted (scope finalizers are LIFO). Patching to
540
- // an empty tree fires snabbdom destroy hooks, which is what releases
541
- // Mounts; swapping the placeholder for the original container leaves
542
- // the host DOM as it was before the first render, ready for a fresh
543
- // embed of the same container. Gated on interruption: that is the
544
- // dispose path. A runtime that stops because it crashed completes
545
- // normally after rendering the crash view, and the crash view must
546
- // stay visible.
547
- yield* Effect.addFinalizer(exit => Effect.gen(function* () {
548
- if (!Exit.hasInterrupts(exit)) {
549
- return;
535
+ const schedulePreserveModel = (model) => hot ? preserveScheduler.schedule(model) : Effect.void;
536
+ // NOTE: the dispatch hot path is plain JavaScript. A dispatched
537
+ // Message is pushed onto a plain array and drained synchronously on
538
+ // the spot, so update runs on the dispatching stack (for example, a
539
+ // DOM event handler, a Command fiber completing, or a Subscription
540
+ // emit) with no fiber hop in between. The drain guards against
541
+ // re-entrancy: a Message dispatched mid-drain (for example, by an
542
+ // update triggered from a synchronous Command) is queued and picked
543
+ // up by the outer drain loop in arrival order, and a Message
544
+ // dispatched while a render frame's patch is on the stack is
545
+ // buffered until the frame completes.
546
+ let pendingMessages = [];
547
+ let isProcessingMessages = false;
548
+ // NOTE: `isRenderFrameScheduled` clears when the frame callback
549
+ // starts, which on the View Transition path is before the patch runs.
550
+ // `commitNotifier` tracks the patch itself, so `Render.afterCommit`
551
+ // waits for the commit rather than for the frame that scheduled it.
552
+ let isRenderFrameScheduled = false;
553
+ // NOTE: mirrors the old queue's boot behavior: a Message arriving
554
+ // before boot completes (for example, a navigation event during an
555
+ // async dev-mode boot step, or a boot-forked fiber emitting early)
556
+ // is buffered, not processed. Processing against a partially
557
+ // initialized runtime would race the init render, DevTools
558
+ // recording, and Subscription attachment. The flag flips as the
559
+ // last act of boot, which then drains the buffer. enqueueMessage
560
+ // checks it directly, not just the drain: dispatch sources go live
561
+ // mid-boot, before `drainPendingMessages` is initialized, and
562
+ // calling it from a pre-boot dispatch would hit the temporal dead
563
+ // zone.
564
+ let isBootComplete = false;
565
+ // NOTE: mirrors the old queue's post-interrupt behavior: a Message
566
+ // dispatched after the runtime scope closed (for example, an
567
+ // OnUnmount fired by the dispose teardown patch, or a stale DOM
568
+ // handler) is dropped
569
+ // instead of updating a disposed runtime. Set by a finalizer
570
+ // registered at the end of boot, so it runs before
571
+ // earlier-registered teardown (finalizers are LIFO).
572
+ let isRuntimeDisposed = false;
573
+ // NOTE: the differ fires destroy and insert hooks while `patch` is
574
+ // on the stack, and both can dispatch synchronously (for example,
575
+ // an OnUnmount dispatch, or a Mount stream's synchronous first
576
+ // emission). Draining
577
+ // inline would run update, and on a defect the crash renderer,
578
+ // against a DOM the outer patch is still mutating. The frame
579
+ // buffers such dispatches and drains them after it completes.
580
+ let isRenderingFrame = false;
581
+ // NOTE: a crash is terminal. The old runtime's drain fiber died on
582
+ // the first defect, so nothing was processed after a crash; this
583
+ // flag preserves that: the drain stops and later dispatches are
584
+ // dropped, so update, Command forks, and DevTools recording all
585
+ // stop with the crash view on screen.
586
+ let isCrashed = false;
587
+ const enqueueMessage = (message) => {
588
+ if (isRuntimeDisposed || isCrashed) {
589
+ return;
590
+ }
591
+ pendingMessages.push(message);
592
+ if (!isBootComplete || isRenderingFrame) {
593
+ return;
594
+ }
595
+ drainPendingMessages();
596
+ };
597
+ const enqueueMessageEffect = (message) => Effect.sync(() => enqueueMessage(message));
598
+ const initModel = maybeFreezeModel(initModelRaw);
599
+ const modelPubSub = yield* PubSub.unbounded();
600
+ if (import.meta.hot) {
601
+ yield* Effect.addFinalizer(() => Effect.sync(() => duplicateIdScanner?.cancel()));
550
602
  }
551
- const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
552
- yield* Option.match(maybeCurrentVNode, {
553
- onNone: () => Effect.void,
554
- onSome: currentVNode => Effect.sync(() => {
555
- const placeholderNode = __patchVNode(Option.some(currentVNode), null, container).elm;
556
- if (placeholderNode && placeholderNode.parentNode) {
557
- placeholderNode.parentNode.replaceChild(container, placeholderNode);
558
- container.replaceChildren();
559
- }
560
- }),
561
- });
562
- }));
563
- // NOTE: shared by every crash path: the init render, the plain
564
- // message drain and render frame (which reach it through
565
- // `Effect.runFork` from their catch blocks), and the Command and
566
- // Subscription fibers (a Command's Effect and a Subscription's
567
- // Stream are typed with a `never` error channel, so a cause
568
- // escaping one can only be a `resources` Layer build failure or an
569
- // escaped defect, both unrecoverable). Each path catches its own
570
- // cause so a failure surfaces as the crash view instead of dying
571
- // silently and leaving the DOM frozen at the last successful
572
- // render. The first crash wins: concurrent Command fibers can fail
573
- // on the same broken Layer, and only one should report and render.
574
- const crashWith = (cause, maybeMessage) => Effect.sync(() => {
575
- if (isCrashed) {
576
- return;
603
+ if (routingConfig) {
604
+ yield* Effect.acquireRelease(Effect.sync(() => addNavigationEventListeners(enqueueMessage, routingConfig)), removeNavigationEventListeners => Effect.sync(() => removeNavigationEventListeners()));
577
605
  }
578
- isCrashed = true;
579
- const model = liveModel;
580
- const squashed = Cause.squash(cause);
581
- const error = squashed instanceof Error ? squashed : new Error(String(squashed));
582
- renderCrashView({ error, model, message: maybeMessage }, crash, container, vnodeSlot, manageDocument);
583
- });
584
- // NOTE: drain-local state. Kept as plain closure variables instead
585
- // of `Ref`s because nothing else reads or writes them concurrently,
586
- // and JS's single-threaded model already orders writes against
587
- // subsequent reads. `currentMessage` is read by the crash handler.
588
- let currentMessage = Option.none();
589
- let maybeLastDirtyMessage = Option.none();
590
- // NOTE: the DevTools store is installed at most once during boot and
591
- // never replaced. Caching it in a closure variable avoids a
592
- // `Ref.get` on every message and on every render frame (the
593
- // store powers the pause check). Plain `null` rather than `Option`:
594
- // the hot path only ever presence-checks it, and the check should
595
- // stay a bare comparison.
596
- let devToolsStore = null;
597
- const dispatchSync = (message) => {
598
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
599
- enqueueMessage(message);
600
- };
601
- const dispatchAsync = (message) =>
602
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
603
- enqueueMessageEffect(message);
604
- const dispatch = { dispatchAsync, dispatchSync };
605
- const isPausedNow = () => devToolsStore !== null &&
606
- SubscriptionRef.getUnsafe(devToolsStore.stateRef).isPaused;
607
- // NOTE: recording is gated on the DevTools store because the store
608
- // is the only consumer. Without the gate every Mount start and end
609
- // in a production frame would allocate a record just to be sliced
610
- // and dropped.
611
- const mountStartBuffer = [];
612
- const mountEndBuffer = [];
613
- const mountTracker = {
614
- started: (name, args) => {
615
- if (devToolsStore === null) {
616
- return;
606
+ // NOTE: the model and the current vnode are plain closure state.
607
+ // The hot path reads and writes them directly; the cold paths that
608
+ // run inside Effects (crash rendering, the dispose finalizer, the
609
+ // replay render) read the same variables synchronously, so no Ref
610
+ // is needed.
611
+ let liveModel = initModel;
612
+ // NOTE: the Model behind the DOM currently on screen, which is what a
613
+ // View Transition animates away from. Seeded with `initModel` because
614
+ // the init render paints it, and advanced only where a render actually
615
+ // commits. The `viewTransition` predicate never runs before a Message
616
+ // has dirtied the Model, and the init render completes behind the boot
617
+ // barrier, so this is always the model of a paint that happened.
618
+ let lastRenderedModel = initModel;
619
+ // NOTE: the transition the browser is still animating, if any. Held so
620
+ // the runtime can skip it: when a later frame supersedes it, when the
621
+ // runtime crashes, and at teardown, where the browser would otherwise
622
+ // animate over a released container. Declared above `crashWith`, which
623
+ // calls the skip and can run as early as the init render.
624
+ let maybePendingViewTransition = Option.none();
625
+ const skipPendingViewTransition = () => {
626
+ if (Option.isSome(maybePendingViewTransition)) {
627
+ const { value: pendingViewTransition } = maybePendingViewTransition;
628
+ // NOTE: cleared first. An implementation that runs the update
629
+ // callback synchronously would otherwise re-enter this.
630
+ maybePendingViewTransition = Option.none();
631
+ try {
632
+ pendingViewTransition.skipTransition();
633
+ }
634
+ catch {
635
+ // NOTE: skipping runs on teardown and crash paths, so a refusal
636
+ // must not propagate into them.
637
+ }
617
638
  }
618
- mountStartBuffer.push(args === undefined ? { name } : { name, args });
619
- },
620
- ended: (name, args) => {
621
- if (devToolsStore === null) {
639
+ };
640
+ const vnodeSlot = { maybeCurrentVNode: Option.none() };
641
+ // NOTE: registered before any perpetual fiber is forked so it runs
642
+ // after they are interrupted (scope finalizers are LIFO). Patching to
643
+ // an empty tree fires snabbdom destroy hooks, which is what releases
644
+ // Mounts; swapping the placeholder for the original container leaves
645
+ // the host DOM as it was before the first render, ready for a fresh
646
+ // embed of the same container. Gated on interruption: that is the
647
+ // dispose path. A runtime that stops because it crashed completes
648
+ // normally after rendering the crash view, and the crash view must
649
+ // stay visible.
650
+ yield* Effect.addFinalizer(exit => Effect.gen(function* () {
651
+ if (!Exit.hasInterrupts(exit)) {
622
652
  return;
623
653
  }
624
- mountEndBuffer.push(args === undefined ? { name } : { name, args });
625
- },
626
- };
627
- const drainMountEvents = () => {
628
- const starts = mountStartBuffer.slice();
629
- const ends = mountEndBuffer.slice();
630
- mountStartBuffer.length = 0;
631
- mountEndBuffer.length = 0;
632
- return { starts, ends };
633
- };
634
- // NOTE: the fork is deferred one microtask so a Command's Effect
635
- // never begins on the dispatching stack. Commands are facts from
636
- // outside the update loop; their results always arrive
637
- // asynchronously, exactly as under the old queue. The fork runs
638
- // through `Effect.runForkWith` (which starts its fiber
639
- // synchronously, so the child is registered in `runtimeScope`
640
- // before this callback returns), not `Effect.runSyncWith`:
641
- // `runSyncWith` injects a temporary synchronous scheduler into the
642
- // fiber context, the child would inherit it, and every later yield
643
- // in the Command (for example, an op-budget suspension, or a
644
- // Stream step) would
645
- // reschedule through clamped `setTimeout` instead of the browser
646
- // microtask scheduler carried by `runtimeContextForCommands`.
647
- const forkCommand = (command, message) => {
648
- queueMicrotask(() => {
649
- // NOTE: `isCrashed` as well as `isRuntimeDisposed`. A crash is
650
- // terminal but does not dispose the runtime, and a Command forked
651
- // by a Message processed just before the crashing Message sits in
652
- // this microtask when the crash view paints. Without the crash
653
- // check its effect would run behind the crash view, contradicting
654
- // the crash-terminality contract. `crashWith` sets `isCrashed`
655
- // synchronously, so it is already set by the time this runs.
656
- if (isRuntimeDisposed || isCrashed) {
654
+ const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
655
+ yield* Option.match(maybeCurrentVNode, {
656
+ onNone: () => Effect.void,
657
+ onSome: currentVNode => Effect.sync(() => {
658
+ const placeholderNode = __patchVNode(Option.some(currentVNode), null, container).elm;
659
+ if (placeholderNode && placeholderNode.parentNode) {
660
+ placeholderNode.parentNode.replaceChild(container, placeholderNode);
661
+ container.replaceChildren();
662
+ }
663
+ }),
664
+ });
665
+ }));
666
+ // NOTE: shared by every crash path: the init render, the plain
667
+ // message drain and render frame (which reach it through
668
+ // `Effect.runFork` from their catch blocks), and the Command and
669
+ // Subscription fibers (a Command's Effect and a Subscription's
670
+ // Stream are typed with a `never` error channel, so a cause
671
+ // escaping one can only be a `resources` Layer build failure or an
672
+ // escaped defect, both unrecoverable). Each path catches its own
673
+ // cause so a failure surfaces as the crash view instead of dying
674
+ // silently and leaving the DOM frozen at the last successful
675
+ // render. The first crash wins: concurrent Command fibers can fail
676
+ // on the same broken Layer, and only one should report and render.
677
+ const crashWith = (cause, maybeMessage) => Effect.sync(() => {
678
+ if (isCrashed) {
657
679
  return;
658
680
  }
659
- Effect.runForkWith(runtimeContextForCommands)(Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
660
- attributes: command.args ?? {},
661
- }), provideAllResources, Effect.flatMap(enqueueMessageEffect), Effect.catchCause(cause => crashWith(cause, message)))));
681
+ isCrashed = true;
682
+ // NOTE: the crash view should appear at once, not animate in from
683
+ // a snapshot of the state that crashed.
684
+ skipPendingViewTransition();
685
+ const model = liveModel;
686
+ const squashed = Cause.squash(cause);
687
+ const error = squashed instanceof Error ? squashed : new Error(String(squashed));
688
+ renderCrashView({ error, model, message: maybeMessage }, crash, container, vnodeSlot, manageDocument);
662
689
  });
663
- };
664
- const processMessagePlain = (message) => {
665
- const currentModel = liveModel;
666
- const [[nextModelRaw, commands], maybeUpdateDuration] = measureSlowPhase(resolvedSlowUpdate, () => update(currentModel, message));
667
- const nextModel = maybeFreezeModel(nextModelRaw);
668
- reportSlowPhase(resolvedSlowUpdate, maybeUpdateDuration, (durationMs, thresholdMs) => ({
669
- _tag: 'Update',
670
- previousModel: currentModel,
671
- nextModel,
672
- message,
673
- durationMs,
674
- thresholdMs,
675
- }));
676
- if (currentModel !== nextModel) {
677
- liveModel = nextModel;
678
- maybeLastDirtyMessage = Option.some(message);
679
- PubSub.publishUnsafe(modelPubSub, nextModel);
680
- if (import.meta.hot) {
681
- Effect.runSync(schedulePreserveModel(nextModel));
690
+ // NOTE: drain-local state. Kept as plain closure variables instead
691
+ // of `Ref`s because nothing else reads or writes them concurrently,
692
+ // and JS's single-threaded model already orders writes against
693
+ // subsequent reads. `currentMessage` is read by the crash handler.
694
+ let currentMessage = Option.none();
695
+ let maybeLastDirtyMessage = Option.none();
696
+ // NOTE: the DevTools store is installed at most once during boot and
697
+ // never replaced. Caching it in a closure variable avoids a
698
+ // `Ref.get` on every message and on every render frame (the
699
+ // store powers the pause check). Plain `null` rather than `Option`:
700
+ // the hot path only ever presence-checks it, and the check should
701
+ // stay a bare comparison.
702
+ let devToolsStore = null;
703
+ const dispatchSync = (message) => {
704
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
705
+ enqueueMessage(message);
706
+ };
707
+ const dispatchAsync = (message) =>
708
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
709
+ enqueueMessageEffect(message);
710
+ const dispatch = { dispatchAsync, dispatchSync };
711
+ const isPausedNow = () => devToolsStore !== null &&
712
+ SubscriptionRef.getUnsafe(devToolsStore.stateRef).isPaused;
713
+ // NOTE: recording is gated on the DevTools store because the store
714
+ // is the only consumer. Without the gate every Mount start and end
715
+ // in a production frame would allocate a record just to be sliced
716
+ // and dropped.
717
+ const mountStartBuffer = [];
718
+ const mountEndBuffer = [];
719
+ const mountTracker = {
720
+ started: (name, args) => {
721
+ if (devToolsStore === null) {
722
+ return;
723
+ }
724
+ mountStartBuffer.push(args === undefined ? { name } : { name, args });
725
+ },
726
+ ended: (name, args) => {
727
+ if (devToolsStore === null) {
728
+ return;
729
+ }
730
+ mountEndBuffer.push(args === undefined ? { name } : { name, args });
731
+ },
732
+ };
733
+ const drainMountEvents = () => {
734
+ const starts = mountStartBuffer.slice();
735
+ const ends = mountEndBuffer.slice();
736
+ mountStartBuffer.length = 0;
737
+ mountEndBuffer.length = 0;
738
+ return { starts, ends };
739
+ };
740
+ // NOTE: the fork is deferred one microtask so a Command's Effect
741
+ // never begins on the dispatching stack. Commands are facts from
742
+ // outside the update loop; their results always arrive
743
+ // asynchronously, exactly as under the old queue. The fork runs
744
+ // through `Effect.runForkWith` (which starts its fiber
745
+ // synchronously, so the child is registered in `runtimeScope`
746
+ // before this callback returns), not `Effect.runSyncWith`:
747
+ // `runSyncWith` injects a temporary synchronous scheduler into the
748
+ // fiber context, the child would inherit it, and every later yield
749
+ // in the Command (for example, an op-budget suspension, or a
750
+ // Stream step) would
751
+ // reschedule through clamped `setTimeout` instead of the browser
752
+ // microtask scheduler carried by `runtimeContextForCommands`.
753
+ const forkCommand = (command, message) => {
754
+ queueMicrotask(() => {
755
+ // NOTE: `isCrashed` as well as `isRuntimeDisposed`. A crash is
756
+ // terminal but does not dispose the runtime, and a Command forked
757
+ // by a Message processed just before the crashing Message sits in
758
+ // this microtask when the crash view paints. Without the crash
759
+ // check its effect would run behind the crash view, contradicting
760
+ // the crash-terminality contract. `crashWith` sets `isCrashed`
761
+ // synchronously, so it is already set by the time this runs.
762
+ if (isRuntimeDisposed || isCrashed) {
763
+ return;
764
+ }
765
+ Effect.runForkWith(runtimeContextForCommands)(Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
766
+ attributes: command.args ?? {},
767
+ }), provideAllResources, Effect.flatMap(enqueueMessageEffect), Effect.catchCause(cause => crashWith(cause, message)))));
768
+ });
769
+ };
770
+ const processMessagePlain = (message) => {
771
+ const currentModel = liveModel;
772
+ const [[nextModelRaw, commands], maybeUpdateDuration] = measureSlowPhase(resolvedSlowUpdate, () => update(currentModel, message));
773
+ const nextModel = maybeFreezeModel(nextModelRaw);
774
+ reportSlowPhase(resolvedSlowUpdate, maybeUpdateDuration, (durationMs, thresholdMs) => ({
775
+ _tag: 'Update',
776
+ previousModel: currentModel,
777
+ nextModel,
778
+ message,
779
+ durationMs,
780
+ thresholdMs,
781
+ }));
782
+ if (currentModel !== nextModel) {
783
+ liveModel = nextModel;
784
+ maybeLastDirtyMessage = Option.some(message);
785
+ PubSub.publishUnsafe(modelPubSub, nextModel);
786
+ if (import.meta.hot) {
787
+ Effect.runSync(schedulePreserveModel(nextModel));
788
+ }
789
+ scheduleRenderFrame();
682
790
  }
683
- scheduleRenderFrame();
684
- }
685
- if (!Array.isReadonlyArrayEmpty(commands)) {
686
- for (const command of commands) {
687
- forkCommand(
688
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
689
- command, Option.some(message));
791
+ if (!Array.isReadonlyArrayEmpty(commands)) {
792
+ for (const command of commands) {
793
+ forkCommand(
794
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
795
+ command, Option.some(message));
796
+ }
690
797
  }
691
- }
692
- // NOTE: store writes go through `Effect.runFork`, not
693
- // `Effect.runSync`. Both complete inline when the store's state
694
- // Ref is uncontended (the always case on this path), but a
695
- // DevTools fiber holding the Ref's permit across a yield would
696
- // make `runSync` throw and crash the app; `runFork` parks and
697
- // finishes the write when the permit frees, and the Ref's FIFO
698
- // permit queue preserves write order.
699
- if (devToolsStore !== null) {
700
- const store = devToolsStore;
701
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
702
- const tag = message._tag;
703
- const isModelChanged = currentModel !== nextModel;
704
- if (!excludeFromHistoryTags.has(tag)) {
705
- Effect.runFork(store.recordMessage(
706
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
707
- message, currentModel, nextModel, Array.map(
798
+ // NOTE: store writes go through `Effect.runFork`, not
799
+ // `Effect.runSync`. Both complete inline when the store's state
800
+ // Ref is uncontended (the always case on this path), but a
801
+ // DevTools fiber holding the Ref's permit across a yield would
802
+ // make `runSync` throw and crash the app; `runFork` parks and
803
+ // finishes the write when the permit frees, and the Ref's FIFO
804
+ // permit queue preserves write order.
805
+ if (devToolsStore !== null) {
806
+ const store = devToolsStore;
708
807
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
709
- commands, toCommandRecord), isModelChanged));
808
+ const tag = message._tag;
809
+ const isModelChanged = currentModel !== nextModel;
810
+ if (!excludeFromHistoryTags.has(tag)) {
811
+ Effect.runFork(store.recordMessage(
812
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
813
+ message, currentModel, nextModel, Array.map(
814
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
815
+ commands, toCommandRecord), isModelChanged));
816
+ }
817
+ else if (isModelChanged) {
818
+ Effect.runFork(store.updateLatestModel(nextModel));
819
+ }
710
820
  }
711
- else if (isModelChanged) {
712
- Effect.runFork(store.updateLatestModel(nextModel));
821
+ };
822
+ // NOTE: escape hatch for synchronous bursts, so the page keeps
823
+ // painting under pathological load (for example, a fiber
824
+ // dispatching thousands of Messages in one task, or a fully
825
+ // synchronous Command chain). Bursts
826
+ // arrive as many single-Message drains within one browser task, so
827
+ // the budget is cumulative across drains: it accumulates processing
828
+ // time and resets when the browser demonstrably got control back (a
829
+ // render frame ran, or the gap since the last drain exceeds the
830
+ // budget). Once over budget, processing defers to a MessageChannel
831
+ // tick, which starts a new task so a pending frame can paint.
832
+ // setTimeout(0) would be clamped to 4ms+; MessageChannel delivers in
833
+ // ~0.5ms. The normal path pays two clock reads per drain.
834
+ let syncWorkMsSinceYield = 0;
835
+ let lastDrainEndedAt = 0;
836
+ let isDrainDeferredToNextTask = false;
837
+ let maybeDeferredDrainChannel = null;
838
+ const scheduleDeferredDrain = () => {
839
+ if (maybeDeferredDrainChannel === null) {
840
+ maybeDeferredDrainChannel = new MessageChannel();
841
+ maybeDeferredDrainChannel.port2.onmessage = () => {
842
+ isDrainDeferredToNextTask = false;
843
+ syncWorkMsSinceYield = 0;
844
+ drainPendingMessages();
845
+ };
713
846
  }
714
- }
715
- };
716
- // NOTE: escape hatch for synchronous bursts, so the page keeps
717
- // painting under pathological load (for example, a fiber
718
- // dispatching thousands of Messages in one task, or a fully
719
- // synchronous Command chain). Bursts
720
- // arrive as many single-Message drains within one browser task, so
721
- // the budget is cumulative across drains: it accumulates processing
722
- // time and resets when the browser demonstrably got control back (a
723
- // render frame ran, or the gap since the last drain exceeds the
724
- // budget). Once over budget, processing defers to a MessageChannel
725
- // tick, which starts a new task so a pending frame can paint.
726
- // setTimeout(0) would be clamped to 4ms+; MessageChannel delivers in
727
- // ~0.5ms. The normal path pays two clock reads per drain.
728
- let syncWorkMsSinceYield = 0;
729
- let lastDrainEndedAt = 0;
730
- let isDrainDeferredToNextTask = false;
731
- let maybeDeferredDrainChannel = null;
732
- const scheduleDeferredDrain = () => {
733
- if (maybeDeferredDrainChannel === null) {
734
- maybeDeferredDrainChannel = new MessageChannel();
735
- maybeDeferredDrainChannel.port2.onmessage = () => {
736
- isDrainDeferredToNextTask = false;
847
+ isDrainDeferredToNextTask = true;
848
+ maybeDeferredDrainChannel.port1.postMessage(null);
849
+ };
850
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
851
+ if (maybeDeferredDrainChannel !== null) {
852
+ maybeDeferredDrainChannel.port1.close();
853
+ maybeDeferredDrainChannel.port2.close();
854
+ maybeDeferredDrainChannel = null;
855
+ }
856
+ }));
857
+ const drainPendingMessages = () => {
858
+ if (!isBootComplete ||
859
+ isProcessingMessages ||
860
+ isRenderingFrame ||
861
+ isDrainDeferredToNextTask ||
862
+ isRuntimeDisposed ||
863
+ isCrashed) {
864
+ return;
865
+ }
866
+ const drainStartedAt = performance.now();
867
+ if (drainStartedAt - lastDrainEndedAt > DRAIN_BUDGET_MS) {
737
868
  syncWorkMsSinceYield = 0;
738
- drainPendingMessages();
739
- };
740
- }
741
- isDrainDeferredToNextTask = true;
742
- maybeDeferredDrainChannel.port1.postMessage(null);
743
- };
744
- yield* Effect.addFinalizer(() => Effect.sync(() => {
745
- if (maybeDeferredDrainChannel !== null) {
746
- maybeDeferredDrainChannel.port1.close();
747
- maybeDeferredDrainChannel.port2.close();
748
- maybeDeferredDrainChannel = null;
749
- }
750
- }));
751
- const drainPendingMessages = () => {
752
- if (!isBootComplete ||
753
- isProcessingMessages ||
754
- isRenderingFrame ||
755
- isDrainDeferredToNextTask ||
756
- isRuntimeDisposed ||
757
- isCrashed) {
758
- return;
759
- }
760
- const drainStartedAt = performance.now();
761
- if (drainStartedAt - lastDrainEndedAt > DRAIN_BUDGET_MS) {
762
- syncWorkMsSinceYield = 0;
763
- }
764
- if (syncWorkMsSinceYield > DRAIN_BUDGET_MS) {
765
- scheduleDeferredDrain();
766
- return;
767
- }
768
- isProcessingMessages = true;
769
- try {
770
- while (pendingMessages.length > 0) {
771
- const batch = pendingMessages;
772
- pendingMessages = [];
773
- for (let index = 0; index < batch.length; index++) {
774
- const message = batch[index];
775
- currentMessage = Option.some(message);
776
- processMessagePlain(message);
777
- const hasRemainingWork = index + 1 < batch.length || pendingMessages.length > 0;
778
- if (hasRemainingWork &&
779
- syncWorkMsSinceYield + (performance.now() - drainStartedAt) >
780
- DRAIN_BUDGET_MS) {
781
- // NOTE: unprocessed batch Messages arrived before
782
- // anything in pendingMessages, so they go back to the
783
- // front to keep arrival order.
784
- pendingMessages = batch
785
- .slice(index + 1)
786
- .concat(pendingMessages);
787
- scheduleDeferredDrain();
788
- return;
869
+ }
870
+ if (syncWorkMsSinceYield > DRAIN_BUDGET_MS) {
871
+ scheduleDeferredDrain();
872
+ return;
873
+ }
874
+ isProcessingMessages = true;
875
+ try {
876
+ while (pendingMessages.length > 0) {
877
+ const batch = pendingMessages;
878
+ pendingMessages = [];
879
+ for (let index = 0; index < batch.length; index++) {
880
+ const message = batch[index];
881
+ currentMessage = Option.some(message);
882
+ processMessagePlain(message);
883
+ const hasRemainingWork = index + 1 < batch.length || pendingMessages.length > 0;
884
+ if (hasRemainingWork &&
885
+ syncWorkMsSinceYield + (performance.now() - drainStartedAt) >
886
+ DRAIN_BUDGET_MS) {
887
+ // NOTE: unprocessed batch Messages arrived before
888
+ // anything in pendingMessages, so they go back to the
889
+ // front to keep arrival order.
890
+ pendingMessages = batch
891
+ .slice(index + 1)
892
+ .concat(pendingMessages);
893
+ scheduleDeferredDrain();
894
+ return;
895
+ }
789
896
  }
790
897
  }
791
898
  }
792
- }
793
- catch (error) {
794
- Effect.runFork(crashWith(Cause.die(error), currentMessage));
795
- }
796
- finally {
797
- const drainEndedAt = performance.now();
798
- syncWorkMsSinceYield += drainEndedAt - drainStartedAt;
799
- lastDrainEndedAt = drainEndedAt;
800
- isProcessingMessages = false;
801
- }
802
- };
803
- // NOTE: `dispatchService` defaults to the live dispatch but is
804
- // overridable so the DevTools jumpTo render path can pass
805
- // `noOpDispatch`. Mount Effects forked during a replay render still
806
- // execute (so the rendered DOM looks correct: positioning,
807
- // observer attachment, library setup), but their result Messages
808
- // reach a no-op dispatchSync and are never processed.
809
- // This prevents mount-derived Messages from polluting history when
810
- // the user is just inspecting past state.
811
- const render = (model, message, dispatchService = dispatch, renderMode = 'Live') => Effect.gen(function* () {
812
- isRenderingFrame = true;
813
- const runtimeContext = yield* Effect.context();
814
- const maybeLiveRender = Option.liftPredicate(renderMode, mode => mode === 'Live');
815
- if (renderMode === 'Replay') {
816
- beginReplayHtmlRender();
817
- }
818
- const maybeLiveSlowView = Option.flatMap(maybeLiveRender, () => resolvedSlowView);
819
- const maybeLiveSlowPatch = Option.flatMap(maybeLiveRender, () => resolvedSlowPatch);
820
- const [nextDocument, maybeViewDuration] = measureSlowPhase(maybeLiveSlowView, () => {
821
- beginHtmlRender(boundaryRegistry);
822
- setHtmlRuntime(dispatchService.dispatchSync, runtimeContext, boundaryRegistry);
823
- try {
824
- return view(model, htmlBuilder);
899
+ catch (error) {
900
+ Effect.runFork(crashWith(Cause.die(error), currentMessage));
825
901
  }
826
902
  finally {
827
- clearHtmlRuntime();
903
+ const drainEndedAt = performance.now();
904
+ syncWorkMsSinceYield += drainEndedAt - drainStartedAt;
905
+ lastDrainEndedAt = drainEndedAt;
906
+ isProcessingMessages = false;
828
907
  }
829
- });
830
- const nextVNode = nextDocument.body;
831
- reportSlowPhase(maybeLiveSlowView, maybeViewDuration, (durationMs, thresholdMs) => ({
832
- _tag: 'View',
833
- model,
834
- message,
835
- durationMs,
836
- thresholdMs,
837
- }));
838
- const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
839
- const [patchedVNode, maybePatchDuration] = yield* Effect.sync(() => measureSlowPhase(maybeLiveSlowPatch, () => __patchVNode(maybeCurrentVNode, nextVNode, container, boundaryRegistry.dedupeSeen)));
840
- vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
841
- reportSlowPhase(maybeLiveSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
842
- _tag: 'Patch',
843
- model,
844
- message,
845
- durationMs,
846
- thresholdMs,
847
- }));
848
- if (manageDocument) {
849
- yield* Effect.sync(() => applyDocumentMetadata(nextDocument, patchedVNode.elm));
850
- }
851
- if (import.meta.hot) {
852
- yield* Effect.sync(() => duplicateIdScanner?.schedule(patchedVNode.elm));
853
- }
854
- }).pipe(Effect.ensuring(Effect.sync(() => {
855
- isRenderingFrame = false;
856
- endReplayHtmlRender();
857
- drainPendingMessages();
858
- })), Effect.provideService(Dispatch, dispatchService), Effect.provideService(MountTracker, mountTracker));
859
- const isInIframe = window.self !== window.top;
860
- 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 => ({
861
- position: config.position ?? DEFAULT_DEV_TOOLS_POSITION,
862
- mode: resolveDevToolsMode(config.mode ?? DEFAULT_DEV_TOOLS_MODE),
863
- maybeBanner: Option.fromNullishOr(config.banner),
864
- maybeOverlay: Option.fromNullishOr(config.overlay),
865
- })));
866
- if (Option.isSome(resolvedDevTools)) {
867
- const { position, mode, maybeBanner, maybeOverlay } = resolvedDevTools.value;
868
- // NOTE: when excludeFromHistory is active, the runtime drops
869
- // excluded Messages from the recorded history. Replay walks the
870
- // recorded entries forward from the nearest keyframe. With
871
- // exclusion, the dropped Messages aren't in that walk, so any
872
- // cumulative state they would have produced is missing from the
873
- // replayed model. Setting keyframeInterval to 1 stores a full
874
- // snapshot on every recorded entry, so time-travel becomes a
875
- // direct lookup that reflects the real live state at the moment
876
- // the entry was recorded.
877
- const isExcludingMessages = excludeFromHistoryTags.size > 0;
878
- const store = yield* createDevToolsStore({
879
- /* eslint-disable @typescript-eslint/consistent-type-assertions */
880
- replay: (model, message) => {
881
- const [updatedModel] = update(model, message);
882
- return maybeFreezeModel(updatedModel);
883
- },
884
- /* eslint-enable @typescript-eslint/consistent-type-assertions */
885
- // NOTE: passes `noOpDispatch` so mount Effects forked during
886
- // the replay render dispatch their result Messages into a
887
- // no-op (instead of enqueueing them as new history entries).
888
- // Also discards mount events fired during the render so they
889
- // don't get attributed to the next user-initiated dispatch.
890
- render: model => Effect.gen(function* () {
891
- yield* render(
908
+ };
909
+ // NOTE: `dispatchService` defaults to the live dispatch but is
910
+ // overridable so the DevTools jumpTo render path can pass
911
+ // `noOpDispatch`. Mount Effects forked during a replay render still
912
+ // execute (so the rendered DOM looks correct: positioning,
913
+ // observer attachment, library setup), but their result Messages
914
+ // reach a no-op dispatchSync and are never processed.
915
+ // This prevents mount-derived Messages from polluting history when
916
+ // the user is just inspecting past state.
917
+ const render = (model, message, dispatchService = dispatch, renderMode = 'Live') => Effect.gen(function* () {
918
+ isRenderingFrame = true;
919
+ const runtimeContext = yield* Effect.context();
920
+ const maybeLiveRender = Option.liftPredicate(renderMode, mode => mode === 'Live');
921
+ if (renderMode === 'Replay') {
922
+ beginReplayHtmlRender();
923
+ }
924
+ const maybeLiveSlowView = Option.flatMap(maybeLiveRender, () => resolvedSlowView);
925
+ const maybeLiveSlowPatch = Option.flatMap(maybeLiveRender, () => resolvedSlowPatch);
926
+ const [nextDocument, maybeViewDuration] = measureSlowPhase(maybeLiveSlowView, () => {
927
+ beginHtmlRender(boundaryRegistry);
928
+ setHtmlRuntime(dispatchService.dispatchSync, runtimeContext, boundaryRegistry);
929
+ try {
930
+ return view(model, htmlBuilder);
931
+ }
932
+ finally {
933
+ clearHtmlRuntime();
934
+ }
935
+ });
936
+ const nextVNode = nextDocument.body;
937
+ reportSlowPhase(maybeLiveSlowView, maybeViewDuration, (durationMs, thresholdMs) => ({
938
+ _tag: 'View',
939
+ model,
940
+ message,
941
+ durationMs,
942
+ thresholdMs,
943
+ }));
944
+ const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
945
+ const [patchedVNode, maybePatchDuration] = yield* Effect.sync(() => measureSlowPhase(maybeLiveSlowPatch, () => __patchVNode(maybeCurrentVNode, nextVNode, container, boundaryRegistry.dedupeSeen)));
946
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
947
+ reportSlowPhase(maybeLiveSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
948
+ _tag: 'Patch',
949
+ model,
950
+ message,
951
+ durationMs,
952
+ thresholdMs,
953
+ }));
954
+ if (manageDocument) {
955
+ yield* Effect.sync(() => applyDocumentMetadata(nextDocument, patchedVNode.elm));
956
+ }
957
+ if (import.meta.hot) {
958
+ yield* Effect.sync(() => duplicateIdScanner?.schedule(patchedVNode.elm));
959
+ }
960
+ }).pipe(Effect.ensuring(Effect.sync(() => {
961
+ isRenderingFrame = false;
962
+ endReplayHtmlRender();
963
+ drainPendingMessages();
964
+ })), Effect.provideService(Dispatch, dispatchService), Effect.provideService(MountTracker, mountTracker));
965
+ const isInIframe = window.self !== window.top;
966
+ 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 => ({
967
+ position: config.position ?? DEFAULT_DEV_TOOLS_POSITION,
968
+ mode: resolveDevToolsMode(config.mode ?? DEFAULT_DEV_TOOLS_MODE),
969
+ maybeBanner: Option.fromNullishOr(config.banner),
970
+ maybeOverlay: Option.fromNullishOr(registeredDevToolsOverlay),
971
+ })));
972
+ if (Option.isSome(resolvedDevTools)) {
973
+ const { position, mode, maybeBanner, maybeOverlay } = resolvedDevTools.value;
974
+ // NOTE: when excludeFromHistory is active, the runtime drops
975
+ // excluded Messages from the recorded history. Replay walks the
976
+ // recorded entries forward from the nearest keyframe. With
977
+ // exclusion, the dropped Messages aren't in that walk, so any
978
+ // cumulative state they would have produced is missing from the
979
+ // replayed model. Setting keyframeInterval to 1 stores a full
980
+ // snapshot on every recorded entry, so time-travel becomes a
981
+ // direct lookup that reflects the real live state at the moment
982
+ // the entry was recorded.
983
+ const isExcludingMessages = excludeFromHistoryTags.size > 0;
984
+ const store = yield* createDevToolsStore({
985
+ /* eslint-disable @typescript-eslint/consistent-type-assertions */
986
+ replay: (model, message) => {
987
+ const [updatedModel] = update(model, message);
988
+ return maybeFreezeModel(updatedModel);
989
+ },
990
+ /* eslint-enable @typescript-eslint/consistent-type-assertions */
991
+ // NOTE: passes `noOpDispatch` so mount Effects forked during
992
+ // the replay render dispatch their result Messages into a
993
+ // no-op (instead of enqueueing them as new history entries).
994
+ // Also discards mount events fired during the render so they
995
+ // don't get attributed to the next user-initiated dispatch.
996
+ render: model => Effect.gen(function* () {
997
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
998
+ const replayedModel = model;
999
+ // NOTE: a transition still animating belongs to the live
1000
+ // state this replay is about to paint over. Left running it
1001
+ // animates a dead snapshot across the replayed DOM.
1002
+ skipPendingViewTransition();
1003
+ yield* render(replayedModel, Option.none(), noOpDispatch, 'Replay');
1004
+ drainMountEvents();
1005
+ // NOTE: a replay paints a past Model, so it owns the DOM on
1006
+ // screen until the next live frame. Leaving
1007
+ // `lastRenderedModel` on the pre-pause Model would hand the
1008
+ // `viewTransition` predicate a `previousModel` describing a
1009
+ // DOM that no longer exists, and the frame `resume`
1010
+ // schedules would animate the wrong direction out of the
1011
+ // wrong snapshot.
1012
+ lastRenderedModel = replayedModel;
1013
+ // NOTE: the Message that dirtied the pre-pause frame does not
1014
+ // describe this repaint. Clearing it means the frame `resume`
1015
+ // schedules renders plainly, matching the documented rule
1016
+ // that time-travel never animates.
1017
+ maybeLastDirtyMessage = Option.none();
1018
+ }),
1019
+ // NOTE: `resume` calls this after a jumpTo render attached DOM
1020
+ // listeners to `noOpDispatch`. Scheduling a frame renders the
1021
+ // live model with live dispatch and rebinds listeners.
1022
+ markRenderPending: Effect.sync(() => scheduleRenderFrame()),
1023
+ }, {
1024
+ ...(devToolsKeyframeInterval !== undefined && {
1025
+ keyframeInterval: devToolsKeyframeInterval,
1026
+ }),
1027
+ ...(devToolsMaxEntries !== undefined && {
1028
+ maxEntries: devToolsMaxEntries,
1029
+ }),
1030
+ // NOTE: exclusion forces keyframeInterval to 1 regardless of any
1031
+ // configured value, since excluded Messages are never replayed
1032
+ // and a denser interval would leave gaps in the replayed model.
1033
+ // Spread last so it wins over `keyframeInterval` above.
1034
+ ...(isExcludingMessages && { keyframeInterval: 1 }),
1035
+ });
1036
+ devToolsStore = store;
1037
+ // NOTE: init is recorded after the init render below, so the
1038
+ // mount buffer reflects the Mounts that fired on the first paint.
1039
+ yield* Option.match(maybeOverlay, {
1040
+ onNone: () => Effect.void,
1041
+ onSome: overlay => overlay(store, position, mode, maybeBanner),
1042
+ });
1043
+ if (import.meta.hot) {
1044
+ const maybeMessageSchema = devTools !== undefined && devTools !== false
1045
+ ? Option.fromNullishOr(devTools.Message)
1046
+ : Option.none();
1047
+ yield* startWebSocketBridge(store, import.meta.hot,
892
1048
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
893
- model, Option.none(), noOpDispatch, 'Replay');
894
- drainMountEvents();
895
- }),
896
- // NOTE: `resume` calls this after a jumpTo render attached DOM
897
- // listeners to `noOpDispatch`. Scheduling a frame renders the
898
- // live model with live dispatch and rebinds listeners.
899
- markRenderPending: Effect.sync(() => scheduleRenderFrame()),
900
- }, {
901
- ...(devToolsKeyframeInterval !== undefined && {
902
- keyframeInterval: devToolsKeyframeInterval,
903
- }),
904
- ...(devToolsMaxEntries !== undefined && {
905
- maxEntries: devToolsMaxEntries,
906
- }),
907
- // NOTE: exclusion forces keyframeInterval to 1 regardless of any
908
- // configured value, since excluded Messages are never replayed
909
- // and a denser interval would leave gaps in the replayed model.
910
- // Spread last so it wins over `keyframeInterval` above.
911
- ...(isExcludingMessages && { keyframeInterval: 1 }),
912
- });
913
- devToolsStore = store;
914
- // NOTE: init is recorded after the init render below, so the
915
- // mount buffer reflects the Mounts that fired on the first paint.
916
- yield* Option.match(maybeOverlay, {
917
- onNone: () => Effect.void,
918
- onSome: overlay => overlay(store, position, mode, maybeBanner),
919
- });
920
- if (import.meta.hot) {
921
- const maybeMessageSchema = devTools !== undefined && devTools !== false
922
- ? Option.fromNullishOr(devTools.Message)
923
- : Option.none();
924
- yield* startWebSocketBridge(store, import.meta.hot,
925
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
926
- message => enqueueMessageEffect(message),
927
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
928
- maybeMessageSchema);
929
- }
930
- }
931
- const initRenderExit = yield* Effect.exit(render(initModel, Option.none()));
932
- if (Exit.isFailure(initRenderExit)) {
933
- yield* crashWith(initRenderExit.cause, Option.none());
934
- // NOTE: suspend instead of returning. Completing would close the
935
- // runtime scope and tear down the crash view; the scope must stay
936
- // open until the runtime is interrupted (dispose, or page unload).
937
- return yield* Effect.never;
938
- }
939
- if (isPreserveScrollActive) {
940
- yield* restorePreservedScrollPosition(runtimeId);
941
- }
942
- const initMountEvents = drainMountEvents();
943
- if (devToolsStore !== null) {
944
- yield* devToolsStore.recordInit(initModel, Array.map(initCommands, toCommandRecord), initMountEvents.starts);
945
- }
946
- // NOTE: maybeLastDirtyMessage holds the most recent dirtying
947
- // Message, so slow render-phase callbacks during high-rate bursts attribute
948
- // to the last Message in the frame batch, not the specific one that
949
- // pushed the view past threshold. Acceptable for a debug callback;
950
- // full attribution would require correlating each message with its
951
- // render contribution, which isn't worth the complexity.
952
- // NOTE: render frames run as plain JavaScript inside the
953
- // requestAnimationFrame callback. Messages arriving between frames
954
- // mark at most one pending frame; the callback renders once with the
955
- // latest model. The runtime context for OnMount forking and Command
956
- // forking is captured once here; it is constant for the lifetime of
957
- // the runtime.
958
- const runtimeContextForCommands = yield* Effect.context();
959
- const liveRenderContext = Context.add(Context.add(runtimeContextForCommands, Dispatch, dispatch), MountTracker, mountTracker);
960
- const renderFramePlain = () => {
961
- isRenderFrameScheduled = false;
962
- // NOTE: a frame scheduled before disposal fires after it; a
963
- // disposed runtime must not repaint the released container.
964
- if (isRuntimeDisposed) {
965
- return;
966
- }
967
- // NOTE: a frame is running, so the browser got control back; the
968
- // drain budget starts fresh.
969
- syncWorkMsSinceYield = 0;
970
- // NOTE: a Message that dirtied the model can also be the one
971
- // whose Command crashed the runtime. Without this guard the
972
- // next animation frame would render the live view over the
973
- // crash view.
974
- if (isCrashed) {
975
- return;
976
- }
977
- if (isPausedNow()) {
978
- return;
979
- }
980
- isRenderingFrame = true;
981
- try {
982
- renderSyncPlain(liveModel, maybeLastDirtyMessage);
983
- if (devToolsStore !== null) {
984
- const mountEvents = drainMountEvents();
985
- Effect.runFork(devToolsStore.attachRenderedMounts(mountEvents.starts, mountEvents.ends));
1049
+ message => enqueueMessageEffect(message),
1050
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1051
+ maybeMessageSchema);
986
1052
  }
987
1053
  }
988
- catch (error) {
989
- Effect.runFork(crashWith(Cause.die(error), maybeLastDirtyMessage));
1054
+ const initRenderExit = yield* Effect.exit(render(initModel, Option.none()));
1055
+ if (Exit.isFailure(initRenderExit)) {
1056
+ yield* crashWith(initRenderExit.cause, Option.none());
1057
+ // NOTE: suspend instead of returning. Completing would close the
1058
+ // runtime scope and tear down the crash view; the scope must stay
1059
+ // open until the runtime is interrupted (dispose, or page unload).
1060
+ return yield* Effect.never;
990
1061
  }
991
- finally {
992
- isRenderingFrame = false;
1062
+ if (isPreserveScrollActive) {
1063
+ yield* restorePreservedScrollPosition(runtimeId);
993
1064
  }
994
- // NOTE: Messages dispatched by patch-time hooks (for example,
995
- // OnUnmount destroys, or Mount emissions) were buffered while the
996
- // frame held the stack; they process now, after the patch has
997
- // committed and the frame's Mount events are attributed.
998
- drainPendingMessages();
999
- };
1000
- const renderSyncPlain = (model, maybeMessage) => {
1001
- const [nextDocument, maybeViewDuration] = measureSlowPhase(resolvedSlowView, () => {
1002
- beginHtmlRender(boundaryRegistry);
1003
- setHtmlRuntime(dispatch.dispatchSync, liveRenderContext, boundaryRegistry);
1065
+ const initMountEvents = drainMountEvents();
1066
+ if (devToolsStore !== null) {
1067
+ yield* devToolsStore.recordInit(initModel, Array.map(initCommands, toCommandRecord), initMountEvents.starts);
1068
+ }
1069
+ // NOTE: maybeLastDirtyMessage holds the most recent dirtying
1070
+ // Message, so slow render-phase callbacks during high-rate bursts attribute
1071
+ // to the last Message in the frame batch, not the specific one that
1072
+ // pushed the view past threshold. Acceptable for a debug callback;
1073
+ // full attribution would require correlating each message with its
1074
+ // render contribution, which isn't worth the complexity.
1075
+ // NOTE: render frames run as plain JavaScript inside the
1076
+ // requestAnimationFrame callback. Messages arriving between frames
1077
+ // mark at most one pending frame; the callback renders once with the
1078
+ // latest model. The runtime context for OnMount forking and Command
1079
+ // forking is captured once here; it is constant for the lifetime of
1080
+ // the runtime.
1081
+ const runtimeContextForCommands = yield* Effect.context();
1082
+ const liveRenderContext = Context.add(Context.add(runtimeContextForCommands, Dispatch, dispatch), MountTracker, mountTracker);
1083
+ // NOTE: the render, Mount drain, DevTools attribution, and
1084
+ // patch-time-buffer flush. Shared by the plain path (called directly)
1085
+ // and the View Transition path (called from the transition's update
1086
+ // callback), which run identical work; only whether they run inside
1087
+ // `document.startViewTransition` differs. `isRenderingFrame` gates the
1088
+ // buffering of Messages dispatched by patch-time hooks, so it must
1089
+ // wrap the actual patch, which on the transition path happens inside
1090
+ // the callback, not when the frame is scheduled.
1091
+ const runRenderFrameBody = () => {
1092
+ isRenderingFrame = true;
1093
+ // NOTE: captured before the patch, because `drainPendingMessages`
1094
+ // below can advance `liveModel` again before the next frame reads
1095
+ // it. What this frame painted is what the next transition animates
1096
+ // away from.
1097
+ const renderedModel = liveModel;
1004
1098
  try {
1005
- return view(model, htmlBuilder);
1099
+ renderSyncPlain(liveModel, maybeLastDirtyMessage);
1100
+ // NOTE: after the patch, so a render that threw leaves this on the
1101
+ // Model still on screen, and before `drainPendingMessages` below,
1102
+ // whose handlers can advance `liveModel` again.
1103
+ lastRenderedModel = renderedModel;
1104
+ if (devToolsStore !== null) {
1105
+ const mountEvents = drainMountEvents();
1106
+ Effect.runFork(devToolsStore.attachRenderedMounts(mountEvents.starts, mountEvents.ends));
1107
+ }
1108
+ }
1109
+ catch (error) {
1110
+ Effect.runFork(crashWith(Cause.die(error), maybeLastDirtyMessage));
1006
1111
  }
1007
1112
  finally {
1008
- clearHtmlRuntime();
1113
+ isRenderingFrame = false;
1009
1114
  }
1010
- });
1011
- reportSlowPhase(resolvedSlowView, maybeViewDuration, (durationMs, thresholdMs) => ({
1012
- _tag: 'View',
1013
- model,
1014
- message: maybeMessage,
1015
- durationMs,
1016
- thresholdMs,
1017
- }));
1018
- const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
1019
- const [patchedVNode, maybePatchDuration] = measureSlowPhase(resolvedSlowPatch, () => __patchVNode(maybeCurrentVNode, nextDocument.body, container, boundaryRegistry.dedupeSeen));
1020
- vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
1021
- reportSlowPhase(resolvedSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
1022
- _tag: 'Patch',
1023
- model,
1024
- message: maybeMessage,
1025
- durationMs,
1026
- thresholdMs,
1027
- }));
1028
- if (manageDocument) {
1029
- applyDocumentMetadata(nextDocument, patchedVNode.elm);
1030
- }
1031
- if (import.meta.hot) {
1032
- duplicateIdScanner?.schedule(patchedVNode.elm);
1033
- }
1034
- };
1035
- const scheduleRenderFrame = () => {
1036
- if (isRenderFrameScheduled) {
1037
- return;
1038
- }
1039
- isRenderFrameScheduled = true;
1040
- requestAnimationFrame(renderFramePlain);
1041
- };
1042
- // NOTE: reloading on bfcache restore is a page-level decision, so
1043
- // only a page-owning runtime that manages the document installs the
1044
- // listener. An app started through `embed` carries a host connector
1045
- // and must never force the host page to reload, so it is excluded
1046
- // even when it manages the document.
1047
- //
1048
- // The listener is installed for the page's whole lifetime and is
1049
- // deliberately not torn down with the runtime scope.
1050
- // `BrowserRuntime.runMain` interrupts the runtime on `beforeunload`,
1051
- // which is exactly when the browser freezes the page into the
1052
- // back/forward cache. A scope-bound listener would be removed by that
1053
- // interrupt before the freeze, so the `pageshow` restore would have
1054
- // nothing left to reload and the page would come back blank: the
1055
- // interrupt finalizer empties the container. A full document
1056
- // navigation (the only way into and out of a cross-origin-isolated
1057
- // page) is what exercises this path. Registration is idempotent, so an
1058
- // HMR re-run does not stack listeners.
1059
- if (manageDocument && Option.isNone(maybeConnector)) {
1060
- yield* Effect.sync(() => addBfcacheRestoreListener());
1061
- }
1062
- if (subscriptions) {
1063
- yield* pipe(subscriptions, Record.toEntries, Effect.forEach(([key, { dependenciesSchema, modelToDependencies, keepAliveEquivalence, dependenciesToStream, },]) => Effect.gen(function* () {
1064
- const equivalence = keepAliveEquivalence ??
1065
- Schema.toEquivalence(dependenciesSchema);
1066
- const [initDependencies, maybeInitDependenciesDuration] = measureSlowPhase(resolvedSlowSubscriptionDependencies, () => modelToDependencies(initModel));
1067
- reportSlowPhase(resolvedSlowSubscriptionDependencies, maybeInitDependenciesDuration, (durationMs, thresholdMs) => ({
1068
- _tag: 'SubscriptionDependencies',
1069
- subscriptionKey: key,
1070
- model: initModel,
1115
+ // NOTE: Messages dispatched by patch-time hooks (for example,
1116
+ // OnUnmount destroys, or Mount emissions) were buffered while the
1117
+ // frame held the stack; they process now, after the patch has
1118
+ // committed and the frame's Mount events are attributed.
1119
+ drainPendingMessages();
1120
+ // NOTE: last, so a waiter resumed by the commit observes the same
1121
+ // DOM and the same processed-Message ordering it saw when
1122
+ // `afterCommit` counted frames.
1123
+ commitNotifier.notifyCommitted();
1124
+ };
1125
+ // NOTE: starts a View Transition around this frame's render when the
1126
+ // `viewTransition` predicate matches, returning `true` when it did.
1127
+ // `startViewTransition` invokes its update callback asynchronously
1128
+ // after snapshotting the old DOM, so the callback reads `liveModel`
1129
+ // and `maybeLastDirtyMessage` fresh (the plain loop may have advanced
1130
+ // the model while the browser suppressed rendering) and re-checks the
1131
+ // disposal and crash guards, which can flip while the transition is
1132
+ // pending. The unconfigured path never reaches this function; the
1133
+ // `Option.isNone` check in `renderFramePlain` returns first, so a
1134
+ // runtime without `viewTransition` allocates no per-frame callback.
1135
+ const startFrameViewTransition = (resolved) => {
1136
+ if (resolved.reducedMotionQuery.matches) {
1137
+ return false;
1138
+ }
1139
+ if (Option.isNone(maybeLastDirtyMessage)) {
1140
+ return false;
1141
+ }
1142
+ const maybeDecision = __decideViewTransition(resolved.decide, {
1143
+ previousModel: lastRenderedModel,
1144
+ model: liveModel,
1145
+ message: maybeLastDirtyMessage.value,
1146
+ });
1147
+ if (Option.isNone(maybeDecision)) {
1148
+ return false;
1149
+ }
1150
+ // NOTE: the superseded transition's update callback still runs, so
1151
+ // the patch it was holding is not lost. Skipping explicitly makes
1152
+ // the hand-off deterministic rather than implementation-defined.
1153
+ skipPendingViewTransition();
1154
+ try {
1155
+ const handle = resolved.startViewTransition(() => {
1156
+ // NOTE: `isPausedNow` as well as the disposal and crash guards.
1157
+ // A DevTools jumpTo landing while this callback is outstanding
1158
+ // has already painted a past Model, and patching `liveModel`
1159
+ // over it would replace the replayed DOM the user is inspecting.
1160
+ if (isRuntimeDisposed || isCrashed || isPausedNow()) {
1161
+ commitNotifier.notifyCommitted();
1162
+ return;
1163
+ }
1164
+ runRenderFrameBody();
1165
+ }, maybeDecision.value.maybeTypes);
1166
+ maybePendingViewTransition = Option.some(handle);
1167
+ __silenceViewTransitionRejections(handle);
1168
+ return true;
1169
+ }
1170
+ catch {
1171
+ // NOTE: an escaping throw would leave the rAF callback without a
1172
+ // patch and without settling the commit notifier, parking every
1173
+ // `Render.afterCommit` on this frame forever.
1174
+ return false;
1175
+ }
1176
+ };
1177
+ // NOTE: every path out of a scheduled frame settles the commit
1178
+ // notifier, whether or not it patched. A frame abandoned silently
1179
+ // would strand any `Render.afterCommit` registered against it, and
1180
+ // the Dom helpers that gate on it would never run their DOM work.
1181
+ const renderFramePlain = () => {
1182
+ isRenderFrameScheduled = false;
1183
+ // NOTE: a frame scheduled before disposal fires after it; a
1184
+ // disposed runtime must not repaint the released container.
1185
+ if (isRuntimeDisposed) {
1186
+ commitNotifier.notifyCommitted();
1187
+ return;
1188
+ }
1189
+ // NOTE: a frame is running, so the browser got control back; the
1190
+ // drain budget starts fresh.
1191
+ syncWorkMsSinceYield = 0;
1192
+ // NOTE: a Message that dirtied the model can also be the one
1193
+ // whose Command crashed the runtime. Without this guard the
1194
+ // next animation frame would render the live view over the
1195
+ // crash view.
1196
+ if (isCrashed) {
1197
+ commitNotifier.notifyCommitted();
1198
+ return;
1199
+ }
1200
+ if (isPausedNow()) {
1201
+ commitNotifier.notifyCommitted();
1202
+ return;
1203
+ }
1204
+ // NOTE: the unconfigured path pays one `Option.isNone` check and
1205
+ // renders directly, allocating no per-frame callback. Only a
1206
+ // runtime configured with `viewTransition` reaches
1207
+ // `startFrameViewTransition`, which decides per frame whether to
1208
+ // wrap the render in `document.startViewTransition`. When it does,
1209
+ // the render runs later, inside the transition's update callback.
1210
+ if (Option.isNone(maybeResolvedViewTransition)) {
1211
+ runRenderFrameBody();
1212
+ return;
1213
+ }
1214
+ if (!startFrameViewTransition(maybeResolvedViewTransition.value)) {
1215
+ runRenderFrameBody();
1216
+ }
1217
+ };
1218
+ const renderSyncPlain = (model, maybeMessage) => {
1219
+ const [nextDocument, maybeViewDuration] = measureSlowPhase(resolvedSlowView, () => {
1220
+ beginHtmlRender(boundaryRegistry);
1221
+ setHtmlRuntime(dispatch.dispatchSync, liveRenderContext, boundaryRegistry);
1222
+ try {
1223
+ return view(model, htmlBuilder);
1224
+ }
1225
+ finally {
1226
+ clearHtmlRuntime();
1227
+ }
1228
+ });
1229
+ reportSlowPhase(resolvedSlowView, maybeViewDuration, (durationMs, thresholdMs) => ({
1230
+ _tag: 'View',
1231
+ model,
1232
+ message: maybeMessage,
1071
1233
  durationMs,
1072
1234
  thresholdMs,
1073
1235
  }));
1074
- const latestDependenciesRef = yield* Ref.make(initDependencies);
1075
- const modelChangesStream = Stream.fromPubSub(modelPubSub).pipe(
1076
- // NOTE: Ref.set runs upstream of Stream.changesWith on
1077
- // every model change, so readDependencies() returns
1078
- // current values even when the equivalence filter
1079
- // doesn't emit. Moving this into a tap after
1080
- // changesWith would silently break subscribers whose
1081
- // dependencies are equivalence-stable across model
1082
- // changes.
1083
- Stream.mapEffect(model => Effect.gen(function* () {
1084
- const [dependencies, maybeDependenciesDuration] = measureSlowPhase(resolvedSlowSubscriptionDependencies, () => modelToDependencies(model));
1085
- reportSlowPhase(resolvedSlowSubscriptionDependencies, maybeDependenciesDuration, (durationMs, thresholdMs) => ({
1236
+ const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
1237
+ const [patchedVNode, maybePatchDuration] = measureSlowPhase(resolvedSlowPatch, () => __patchVNode(maybeCurrentVNode, nextDocument.body, container, boundaryRegistry.dedupeSeen));
1238
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
1239
+ reportSlowPhase(resolvedSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
1240
+ _tag: 'Patch',
1241
+ model,
1242
+ message: maybeMessage,
1243
+ durationMs,
1244
+ thresholdMs,
1245
+ }));
1246
+ if (manageDocument) {
1247
+ applyDocumentMetadata(nextDocument, patchedVNode.elm);
1248
+ }
1249
+ if (import.meta.hot) {
1250
+ duplicateIdScanner?.schedule(patchedVNode.elm);
1251
+ }
1252
+ };
1253
+ const scheduleRenderFrame = () => {
1254
+ if (isRenderFrameScheduled) {
1255
+ return;
1256
+ }
1257
+ isRenderFrameScheduled = true;
1258
+ commitNotifier.markCommitPending();
1259
+ requestAnimationFrame(renderFramePlain);
1260
+ };
1261
+ // NOTE: reloading on bfcache restore is a page-level decision, so
1262
+ // only a page-owning runtime that manages the document installs the
1263
+ // listener. An app started through `embed` carries a host connector
1264
+ // and must never force the host page to reload, so it is excluded
1265
+ // even when it manages the document.
1266
+ //
1267
+ // The listener is installed for the page's whole lifetime and is
1268
+ // deliberately not torn down with the runtime scope.
1269
+ // `BrowserRuntime.runMain` interrupts the runtime on `beforeunload`,
1270
+ // which is exactly when the browser freezes the page into the
1271
+ // back/forward cache. A scope-bound listener would be removed by that
1272
+ // interrupt before the freeze, so the `pageshow` restore would have
1273
+ // nothing left to reload and the page would come back blank: the
1274
+ // interrupt finalizer empties the container. A full document
1275
+ // navigation (the only way into and out of a cross-origin-isolated
1276
+ // page) is what exercises this path. Registration is idempotent, so an
1277
+ // HMR re-run does not stack listeners.
1278
+ if (manageDocument && Option.isNone(maybeConnector)) {
1279
+ yield* Effect.sync(() => addBfcacheRestoreListener());
1280
+ }
1281
+ if (subscriptions) {
1282
+ yield* pipe(subscriptions, Record.toEntries, Effect.forEach(([key, { dependenciesSchema, modelToDependencies, keepAliveEquivalence, dependenciesToStream, },]) => Effect.gen(function* () {
1283
+ const equivalence = keepAliveEquivalence ??
1284
+ Schema.toEquivalence(dependenciesSchema);
1285
+ const [initDependencies, maybeInitDependenciesDuration] = measureSlowPhase(resolvedSlowSubscriptionDependencies, () => modelToDependencies(initModel));
1286
+ reportSlowPhase(resolvedSlowSubscriptionDependencies, maybeInitDependenciesDuration, (durationMs, thresholdMs) => ({
1086
1287
  _tag: 'SubscriptionDependencies',
1087
1288
  subscriptionKey: key,
1088
- model,
1289
+ model: initModel,
1089
1290
  durationMs,
1090
1291
  thresholdMs,
1091
1292
  }));
1092
- yield* Ref.set(latestDependenciesRef, dependencies);
1093
- return dependencies;
1094
- })));
1095
- 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 =>
1096
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1097
- enqueueMessageEffect(message)), provideAllResources, Effect.catchCause(cause => crashWith(cause, Option.none()))));
1098
- }), {
1293
+ const latestDependenciesRef = yield* Ref.make(initDependencies);
1294
+ const modelChangesStream = Stream.fromPubSub(modelPubSub).pipe(
1295
+ // NOTE: Ref.set runs upstream of Stream.changesWith on
1296
+ // every model change, so readDependencies() returns
1297
+ // current values even when the equivalence filter
1298
+ // doesn't emit. Moving this into a tap after
1299
+ // changesWith would silently break subscribers whose
1300
+ // dependencies are equivalence-stable across model
1301
+ // changes.
1302
+ Stream.mapEffect(model => Effect.gen(function* () {
1303
+ const [dependencies, maybeDependenciesDuration] = measureSlowPhase(resolvedSlowSubscriptionDependencies, () => modelToDependencies(model));
1304
+ reportSlowPhase(resolvedSlowSubscriptionDependencies, maybeDependenciesDuration, (durationMs, thresholdMs) => ({
1305
+ _tag: 'SubscriptionDependencies',
1306
+ subscriptionKey: key,
1307
+ model,
1308
+ durationMs,
1309
+ thresholdMs,
1310
+ }));
1311
+ yield* Ref.set(latestDependenciesRef, dependencies);
1312
+ return dependencies;
1313
+ })));
1314
+ 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 =>
1315
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1316
+ enqueueMessageEffect(message)), provideAllResources, Effect.catchCause(cause => crashWith(cause, Option.none()))));
1317
+ }), {
1318
+ concurrency: 'unbounded',
1319
+ discard: true,
1320
+ }));
1321
+ }
1322
+ const maybeRequirementsToLifecycle = (config, resourceRef) => (maybeRequirements) => {
1323
+ if (Option.isOption(maybeRequirements) &&
1324
+ Option.isNone(maybeRequirements)) {
1325
+ return Stream.empty;
1326
+ }
1327
+ const requirements = Option.isOption(maybeRequirements)
1328
+ ? Option.getOrThrow(maybeRequirements)
1329
+ : maybeRequirements;
1330
+ const acquire = Effect.gen(function* () {
1331
+ const value = yield* config.acquire(requirements);
1332
+ yield* Ref.set(resourceRef, Option.some(value));
1333
+ return value;
1334
+ });
1335
+ const release = (value) => Effect.gen(function* () {
1336
+ yield* config.release(value);
1337
+ yield* Ref.set(resourceRef, Option.none());
1338
+ yield* enqueueMessageEffect(config.onReleased());
1339
+ }).pipe(Effect.catchCause(() => Effect.void));
1340
+ 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)))));
1341
+ };
1342
+ const forkManagedResourceLifecycle = ({ config, ref: resourceRef, }) => Effect.gen(function* () {
1343
+ const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
1344
+ const equivalence = Schema.toEquivalence(config.schema);
1345
+ yield* Effect.forkIn(runtimeScope)(modelStream.pipe(Stream.map(config.modelToMaybeRequirements), Stream.changesWith(equivalence), Stream.switchMap(maybeRequirementsToLifecycle(config, resourceRef)), Stream.runForEach(Effect.flatMap(enqueueMessageEffect)),
1346
+ // NOTE: mirrors the Subscription fork so a defect in
1347
+ // `modelToMaybeRequirements` or the equivalence surfaces as
1348
+ // the crash view instead of dying silently in this detached
1349
+ // fiber. `provideAllResources` is not needed: `acquire` only
1350
+ // requires `Scope`, which `Stream.scoped` supplies, and
1351
+ // `release` requires nothing.
1352
+ Effect.catchCause(cause => crashWith(cause, Option.none()))));
1353
+ });
1354
+ yield* Effect.forEach(managedResourceRefs, forkManagedResourceLifecycle, {
1099
1355
  concurrency: 'unbounded',
1100
1356
  discard: true,
1357
+ });
1358
+ // NOTE: registered before the boot buffer drains, so an interrupt
1359
+ // landing anywhere after this yield tears down with the flag set
1360
+ // (finalizers are LIFO; this one runs before every
1361
+ // earlier-registered teardown, including the container-restoring
1362
+ // patch whose OnUnmount dispatches must be dropped). An interrupt
1363
+ // landing before this yield tears down with isBootComplete still
1364
+ // false, so every dispatch buffers and dies with the closure.
1365
+ // Either way no Message is processed against a closing runtime.
1366
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
1367
+ isRuntimeDisposed = true;
1368
+ // NOTE: a transition outliving the runtime would keep animating
1369
+ // over a container the teardown is about to restore.
1370
+ skipPendingViewTransition();
1101
1371
  }));
1102
- }
1103
- const maybeRequirementsToLifecycle = (config, resourceRef) => (maybeRequirements) => {
1104
- if (Option.isOption(maybeRequirements) &&
1105
- Option.isNone(maybeRequirements)) {
1106
- return Stream.empty;
1372
+ // NOTE: init Commands fork as the last act of boot, exactly where
1373
+ // the old queue's drain loop used to start. Together with the
1374
+ // isBootComplete barrier this guarantees no Command result (or any
1375
+ // other Message) is processed until the init render has painted
1376
+ // initModel and every boot subsystem (DevTools store, Subscriptions,
1377
+ // ManagedResources, ports) is attached. forkCommand also defers each
1378
+ // start by a microtask, so a fully synchronous init Command still
1379
+ // delivers its result asynchronously.
1380
+ for (const command of initCommands) {
1381
+ forkCommand(
1382
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1383
+ command, Option.none());
1107
1384
  }
1108
- const requirements = Option.isOption(maybeRequirements)
1109
- ? Option.getOrThrow(maybeRequirements)
1110
- : maybeRequirements;
1111
- const acquire = Effect.gen(function* () {
1112
- const value = yield* config.acquire(requirements);
1113
- yield* Ref.set(resourceRef, Option.some(value));
1114
- return value;
1115
- });
1116
- const release = (value) => Effect.gen(function* () {
1117
- yield* config.release(value);
1118
- yield* Ref.set(resourceRef, Option.none());
1119
- yield* enqueueMessageEffect(config.onReleased());
1120
- }).pipe(Effect.catchCause(() => Effect.void));
1121
- 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)))));
1122
- };
1123
- const forkManagedResourceLifecycle = ({ config, ref: resourceRef, }) => Effect.gen(function* () {
1124
- const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
1125
- const equivalence = Schema.toEquivalence(config.schema);
1126
- yield* Effect.forkIn(runtimeScope)(modelStream.pipe(Stream.map(config.modelToMaybeRequirements), Stream.changesWith(equivalence), Stream.switchMap(maybeRequirementsToLifecycle(config, resourceRef)), Stream.runForEach(Effect.flatMap(enqueueMessageEffect)),
1127
- // NOTE: mirrors the Subscription fork so a defect in
1128
- // `modelToMaybeRequirements` or the equivalence surfaces as
1129
- // the crash view instead of dying silently in this detached
1130
- // fiber. `provideAllResources` is not needed: `acquire` only
1131
- // requires `Scope`, which `Stream.scoped` supplies, and
1132
- // `release` requires nothing.
1133
- Effect.catchCause(cause => crashWith(cause, Option.none()))));
1134
- });
1135
- yield* Effect.forEach(managedResourceRefs, forkManagedResourceLifecycle, {
1136
- concurrency: 'unbounded',
1137
- discard: true,
1138
- });
1139
- // NOTE: registered before the boot buffer drains, so an interrupt
1140
- // landing anywhere after this yield tears down with the flag set
1141
- // (finalizers are LIFO; this one runs before every
1142
- // earlier-registered teardown, including the container-restoring
1143
- // patch whose OnUnmount dispatches must be dropped). An interrupt
1144
- // landing before this yield tears down with isBootComplete still
1145
- // false, so every dispatch buffers and dies with the closure.
1146
- // Either way no Message is processed against a closing runtime.
1147
- yield* Effect.addFinalizer(() => Effect.sync(() => {
1148
- isRuntimeDisposed = true;
1149
- }));
1150
- // NOTE: init Commands fork as the last act of boot, exactly where
1151
- // the old queue's drain loop used to start. Together with the
1152
- // isBootComplete barrier this guarantees no Command result (or any
1153
- // other Message) is processed until the init render has painted
1154
- // initModel and every boot subsystem (DevTools store, Subscriptions,
1155
- // ManagedResources, ports) is attached. forkCommand also defers each
1156
- // start by a microtask, so a fully synchronous init Command still
1157
- // delivers its result asynchronously.
1158
- for (const command of initCommands) {
1159
- forkCommand(
1160
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1161
- command, Option.none());
1162
- }
1163
- isBootComplete = true;
1164
- drainPendingMessages();
1165
- // NOTE: suspend forever. Messages are processed synchronously on
1166
- // the dispatching stack and render frames run as plain rAF
1167
- // callbacks, so this fiber's only remaining job is keeping the
1168
- // runtime scope open until interruption (dispose, or page unload).
1169
- yield* Effect.never;
1170
- }));
1385
+ isBootComplete = true;
1386
+ drainPendingMessages();
1387
+ // NOTE: suspend forever. Messages are processed synchronously on
1388
+ // the dispatching stack and render frames run as plain rAF
1389
+ // callbacks, so this fiber's only remaining job is keeping the
1390
+ // runtime scope open until interruption (dispose, or page unload).
1391
+ yield* Effect.never;
1392
+ })).pipe(Effect.provideService(RenderCommit, commitNotifier.service));
1393
+ };
1171
1394
  const start = (hmrModel) => startWith(Option.none(), hmrModel);
1172
1395
  const program = { runtimeId, start, ports };
1173
1396
  runtimeInternals.set(program, {
@@ -1322,6 +1545,9 @@ export function makeApplication(config) {
1322
1545
  ...(Predicate.isNotUndefined(config.slow) && {
1323
1546
  slow: config.slow,
1324
1547
  }),
1548
+ ...(Predicate.isNotUndefined(config.viewTransition) && {
1549
+ viewTransition: config.viewTransition,
1550
+ }),
1325
1551
  ...(Predicate.isNotUndefined(config.freezeModel) && {
1326
1552
  freezeModel: config.freezeModel,
1327
1553
  }),
@@ -1341,7 +1567,7 @@ export function makeApplication(config) {
1341
1567
  return makeRuntime({
1342
1568
  ...baseConfig,
1343
1569
  Flags: config.Flags,
1344
- flags: config.flags,
1570
+ flags: Option.some(config.flags),
1345
1571
  init: (flags, url) => config.init(flags, url ?? currentUrl),
1346
1572
  });
1347
1573
  }
@@ -1349,7 +1575,7 @@ export function makeApplication(config) {
1349
1575
  return makeRuntime({
1350
1576
  ...baseConfig,
1351
1577
  Flags: Schema.Void,
1352
- flags: Effect.succeed(undefined),
1578
+ flags: Option.none(),
1353
1579
  init: (_flags, url) => config.init(url ?? currentUrl),
1354
1580
  });
1355
1581
  }
@@ -1357,7 +1583,7 @@ export function makeApplication(config) {
1357
1583
  return makeRuntime({
1358
1584
  ...baseConfig,
1359
1585
  Flags: config.Flags,
1360
- flags: config.flags,
1586
+ flags: Option.some(config.flags),
1361
1587
  init: (flags) => config.init(flags),
1362
1588
  });
1363
1589
  }
@@ -1365,7 +1591,7 @@ export function makeApplication(config) {
1365
1591
  return makeRuntime({
1366
1592
  ...baseConfig,
1367
1593
  Flags: Schema.Void,
1368
- flags: Effect.succeed(undefined),
1594
+ flags: Option.none(),
1369
1595
  init: () => config.init(),
1370
1596
  });
1371
1597
  }
@@ -1414,6 +1640,9 @@ export function makeElement(config) {
1414
1640
  ...(Predicate.isNotUndefined(config.slow) && {
1415
1641
  slow: config.slow,
1416
1642
  }),
1643
+ ...(Predicate.isNotUndefined(config.viewTransition) && {
1644
+ viewTransition: config.viewTransition,
1645
+ }),
1417
1646
  ...(Predicate.isNotUndefined(config.freezeModel) && {
1418
1647
  freezeModel: config.freezeModel,
1419
1648
  }),
@@ -1430,7 +1659,7 @@ export function makeElement(config) {
1430
1659
  return makeRuntime({
1431
1660
  ...baseConfig,
1432
1661
  Flags: config.Flags,
1433
- flags: config.flags,
1662
+ flags: Option.some(config.flags),
1434
1663
  init: (flags) => config.init(flags),
1435
1664
  });
1436
1665
  }
@@ -1438,7 +1667,7 @@ export function makeElement(config) {
1438
1667
  return makeRuntime({
1439
1668
  ...baseConfig,
1440
1669
  Flags: Schema.Void,
1441
- flags: Effect.succeed(undefined),
1670
+ flags: Option.none(),
1442
1671
  init: () => config.init(),
1443
1672
  });
1444
1673
  }