foldkit 0.136.0 → 0.138.0

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