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.
- package/dist/command/index.d.ts +5 -0
- package/dist/command/index.d.ts.map +1 -1
- package/dist/command/index.js +13 -3
- package/dist/dom/dom.d.ts +13 -0
- package/dist/dom/dom.d.ts.map +1 -1
- package/dist/dom/dom.js +13 -0
- package/dist/file/select.d.ts +2 -2
- package/dist/file/select.js +2 -2
- package/dist/html/index.d.ts +1 -1
- package/dist/html/index.d.ts.map +1 -1
- package/dist/render/commit.d.ts +44 -0
- package/dist/render/commit.d.ts.map +1 -0
- package/dist/render/commit.js +58 -0
- package/dist/render/render.d.ts +12 -7
- package/dist/render/render.d.ts.map +1 -1
- package/dist/render/render.js +45 -17
- package/dist/runtime/public.d.ts +1 -0
- package/dist/runtime/public.d.ts.map +1 -1
- package/dist/runtime/runtime.d.ts +20 -9
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +1013 -789
- package/dist/runtime/viewTransition.d.ts +69 -0
- package/dist/runtime/viewTransition.d.ts.map +1 -0
- package/dist/runtime/viewTransition.js +37 -0
- package/dist/test/apps/attributes.js +1 -1
- package/dist/test/apps/crashOnRender.d.ts +3 -3
- package/dist/test/apps/crashOnRender.d.ts.map +1 -1
- package/dist/test/apps/crashOnRender.js +7 -5
- package/dist/test/apps/formChild.d.ts +5 -5
- package/dist/test/apps/formChild.d.ts.map +1 -1
- package/dist/test/apps/formChild.js +5 -5
- package/dist/test/apps/mountPanel.js +1 -1
- package/dist/test/apps/resumeUpload.d.ts +3 -3
- package/dist/test/apps/resumeUpload.d.ts.map +1 -1
- package/dist/test/apps/resumeUpload.js +5 -5
- package/package.json +1 -1
package/dist/runtime/runtime.js
CHANGED
|
@@ -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:
|
|
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) =>
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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
|
-
|
|
898
|
+
const drainEndedAt = performance.now();
|
|
899
|
+
syncWorkMsSinceYield += drainEndedAt - drainStartedAt;
|
|
900
|
+
lastDrainEndedAt = drainEndedAt;
|
|
901
|
+
isProcessingMessages = false;
|
|
828
902
|
}
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
894
|
-
|
|
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
|
-
|
|
989
|
-
|
|
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
|
-
|
|
992
|
-
|
|
1057
|
+
if (isPreserveScrollActive) {
|
|
1058
|
+
yield* restorePreservedScrollPosition(runtimeId);
|
|
993
1059
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1108
|
+
isRenderingFrame = false;
|
|
1009
1109
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
|
1075
|
-
const
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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.
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
1665
|
+
flags: Option.none(),
|
|
1442
1666
|
init: () => config.init(),
|
|
1443
1667
|
});
|
|
1444
1668
|
}
|