foldkit 0.157.0 → 0.158.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/devTools/host.d.ts +2 -2
  2. package/dist/devTools/host.d.ts.map +1 -1
  3. package/dist/devTools/host.js +1 -1
  4. package/dist/experimental/machine/machine.d.ts +278 -84
  5. package/dist/experimental/machine/machine.d.ts.map +1 -1
  6. package/dist/experimental/machine/machine.js +362 -212
  7. package/dist/runtime/browserListeners.d.ts +7 -1
  8. package/dist/runtime/browserListeners.d.ts.map +1 -1
  9. package/dist/runtime/browserScheduler.d.ts +3 -0
  10. package/dist/runtime/browserScheduler.d.ts.map +1 -0
  11. package/dist/runtime/browserScheduler.js +22 -0
  12. package/dist/runtime/crashUI.d.ts +36 -5
  13. package/dist/runtime/crashUI.d.ts.map +1 -1
  14. package/dist/runtime/crashUI.js +55 -2
  15. package/dist/runtime/devToolsConfig.d.ts +85 -0
  16. package/dist/runtime/devToolsConfig.d.ts.map +1 -0
  17. package/dist/runtime/devToolsConfig.js +49 -0
  18. package/dist/runtime/devToolsIntegration.d.ts +56 -0
  19. package/dist/runtime/devToolsIntegration.d.ts.map +1 -0
  20. package/dist/runtime/devToolsIntegration.js +186 -0
  21. package/dist/runtime/dispatch.d.ts +10 -0
  22. package/dist/runtime/dispatch.d.ts.map +1 -0
  23. package/dist/runtime/dispatch.js +4 -0
  24. package/dist/runtime/documentMetadata.d.ts +3 -0
  25. package/dist/runtime/documentMetadata.d.ts.map +1 -0
  26. package/dist/runtime/documentMetadata.js +148 -0
  27. package/dist/runtime/duplicateIdScanner.d.ts +16 -0
  28. package/dist/runtime/duplicateIdScanner.d.ts.map +1 -0
  29. package/dist/runtime/duplicateIdScanner.js +70 -0
  30. package/dist/runtime/hmrModelBridge.d.ts +4 -0
  31. package/dist/runtime/hmrModelBridge.d.ts.map +1 -0
  32. package/dist/runtime/hmrModelBridge.js +43 -0
  33. package/dist/runtime/hostConnector.d.ts +61 -0
  34. package/dist/runtime/hostConnector.d.ts.map +1 -0
  35. package/dist/runtime/hostConnector.js +141 -0
  36. package/dist/runtime/hydrationHandoff.d.ts +44 -0
  37. package/dist/runtime/hydrationHandoff.d.ts.map +1 -0
  38. package/dist/runtime/hydrationHandoff.js +395 -0
  39. package/dist/runtime/index.d.ts +16 -1
  40. package/dist/runtime/index.d.ts.map +1 -1
  41. package/dist/runtime/index.js +5 -1
  42. package/dist/runtime/makeApplication.d.ts +71 -0
  43. package/dist/runtime/makeApplication.d.ts.map +1 -0
  44. package/dist/runtime/makeApplication.js +98 -0
  45. package/dist/runtime/makeElement.d.ts +69 -0
  46. package/dist/runtime/makeElement.d.ts.map +1 -0
  47. package/dist/runtime/makeElement.js +81 -0
  48. package/dist/runtime/managedResourceFibers.d.ts +18 -0
  49. package/dist/runtime/managedResourceFibers.d.ts.map +1 -0
  50. package/dist/runtime/managedResourceFibers.js +47 -0
  51. package/dist/runtime/messageQueue.d.ts +27 -0
  52. package/dist/runtime/messageQueue.d.ts.map +1 -0
  53. package/dist/runtime/messageQueue.js +133 -0
  54. package/dist/runtime/public.d.ts +2 -3
  55. package/dist/runtime/public.d.ts.map +1 -1
  56. package/dist/runtime/public.js +1 -1
  57. package/dist/runtime/renderer.d.ts +69 -0
  58. package/dist/runtime/renderer.d.ts.map +1 -0
  59. package/dist/runtime/renderer.js +512 -0
  60. package/dist/runtime/resourceProvider.d.ts +33 -0
  61. package/dist/runtime/resourceProvider.d.ts.map +1 -0
  62. package/dist/runtime/resourceProvider.js +98 -0
  63. package/dist/runtime/runtime.d.ts +133 -412
  64. package/dist/runtime/runtime.d.ts.map +1 -1
  65. package/dist/runtime/runtime.js +118 -2184
  66. package/dist/runtime/runtimeStatus.d.ts +15 -0
  67. package/dist/runtime/runtimeStatus.d.ts.map +1 -0
  68. package/dist/runtime/runtimeStatus.js +6 -0
  69. package/dist/runtime/slowPhase.d.ts +82 -0
  70. package/dist/runtime/slowPhase.d.ts.map +1 -0
  71. package/dist/runtime/slowPhase.js +83 -0
  72. package/dist/runtime/start.d.ts +96 -0
  73. package/dist/runtime/start.d.ts.map +1 -0
  74. package/dist/runtime/start.js +99 -0
  75. package/dist/runtime/subscriptionFibers.d.ts +21 -0
  76. package/dist/runtime/subscriptionFibers.d.ts.map +1 -0
  77. package/dist/runtime/subscriptionFibers.js +47 -0
  78. package/dist/runtime/visibility.d.ts +8 -0
  79. package/dist/runtime/visibility.d.ts.map +1 -0
  80. package/dist/runtime/visibility.js +6 -0
  81. package/dist/test/scene.js +1 -1
  82. package/package.json +1 -1
@@ -0,0 +1,512 @@
1
+ import { Cause, Context, Effect, Exit, Option } from 'effect';
2
+ import { __beginRender as beginHtmlRender, __beginReplayRender as beginReplayHtmlRender, __clearRuntime as clearHtmlRuntime, __createBoundaryRegistry as createHtmlBoundaryRegistry, __endReplayRender as endReplayHtmlRender, __flushReplayUnmountsAfterPatchFailure as flushReplayUnmountsAfterPatchFailure, __setRuntime as setHtmlRuntime, } from '../html/index.js';
3
+ import { __hydrateVNode } from '../hydrate.js';
4
+ import { FOLDKIT_APP_ATTRIBUTE } from '../hydrationMarker.js';
5
+ import { MountRuntime, MountTracker } from '../mount/index.js';
6
+ import { __patchVNode, __recoverVNodeAfterPatchFailure, } from '../vdom.js';
7
+ import { noOpDispatch, renderCrashView, } from './crashUI.js';
8
+ import { Dispatch } from './dispatch.js';
9
+ import { applyDocumentMetadata } from './documentMetadata.js';
10
+ import { measureSlowPhase, reportSlowPhase, } from './slowPhase.js';
11
+ import { __decideViewTransition, __silenceViewTransitionRejections, } from './viewTransition.js';
12
+ /**
13
+ * Builds the render side of one runtime: the current vnode and, when
14
+ * hydrating, the server-rendered root the first render adopts, the crash
15
+ * renderer, the `Dispatch` service the view binds handlers to, the
16
+ * synchronous render that runs the view and patches the DOM, the render
17
+ * frame that runs in `requestAnimationFrame`, and the View Transition
18
+ * that frame may run inside. It also registers the teardown finalizer
19
+ * that patches the tree away and restores the container, so build it
20
+ * before forking the perpetual fibers: scope finalizers run
21
+ * last-registered first, and the container must be restored only after
22
+ * those fibers are interrupted.
23
+ */
24
+ export const makeRenderer = ({ status, container, view, htmlBuilder, manageDocument, crash, buildId, initModel, maybeHydrationRoot, maybeSlowView, maybeSlowPatch, duplicateIdScanner, maybeResolvedViewTransition, commitNotifier, runtimeContext, readLiveModel, messageQueue, devToolsIntegration, }) => Effect.gen(function* () {
25
+ const { enqueueMessage, enqueueMessageEffect, drainPendingMessages, resetDrainBudget, } = messageQueue;
26
+ const { mountTracker, mountRuntime, drainMountEvents, readViewState, setViewState, isPausedNow, resumeDevTools, attachRenderedMounts, } = devToolsIntegration;
27
+ const settlePendingCommit = () => {
28
+ if (commitNotifier.service.isCommitPending()) {
29
+ commitNotifier.notifyCommitted();
30
+ }
31
+ };
32
+ // NOTE: `isRenderFrameScheduled` clears when the frame callback
33
+ // starts, which on the View Transition path is before the patch runs.
34
+ // `commitNotifier` tracks the patch itself, so `Render.afterCommit`
35
+ // waits for the commit rather than for the frame that scheduled it.
36
+ let isRenderFrameScheduled = false;
37
+ // NOTE: resume clears the DevTools store's pause flag before its frame
38
+ // patches the live view. This distinguishes that intentional repaint
39
+ // from an ordinary frame that was already queued when jumpTo installed
40
+ // the historical view.
41
+ let isLiveViewRestorePending = false;
42
+ // NOTE: the Model behind the DOM currently on screen, which is what a
43
+ // View Transition animates away from. Seeded with `initModel` because
44
+ // the init render paints it, and advanced only where a render actually
45
+ // commits. The `viewTransition` predicate never runs before a Message
46
+ // has dirtied the Model, and the init render completes behind the boot
47
+ // barrier, so this is always the model of a paint that happened.
48
+ let lastRenderedModel = initModel;
49
+ // NOTE: the transition the browser is still animating, if any. Held so
50
+ // the runtime can skip it: when a later frame supersedes it, when the
51
+ // runtime crashes, and at teardown, where the browser would otherwise
52
+ // animate over a released container. Declared above `crashWith`, which
53
+ // calls the skip and can run as early as the init render.
54
+ let maybePendingViewTransition = Option.none();
55
+ const skipPendingViewTransition = () => {
56
+ if (Option.isSome(maybePendingViewTransition)) {
57
+ const { value: pendingViewTransition } = maybePendingViewTransition;
58
+ // NOTE: cleared first. An implementation that runs the update
59
+ // callback synchronously would otherwise re-enter this.
60
+ maybePendingViewTransition = Option.none();
61
+ pendingViewTransition.update.isInvalidated = true;
62
+ try {
63
+ pendingViewTransition.handle.skipTransition();
64
+ }
65
+ catch {
66
+ // NOTE: skipping runs on teardown and crash paths, so a refusal
67
+ // must not propagate into them.
68
+ }
69
+ }
70
+ };
71
+ // NOTE: the current vnode is plain closure state. The frame reads and
72
+ // writes it directly; the cold paths that run inside Effects (crash
73
+ // rendering, the dispose finalizer, the replay render) read the same
74
+ // slot synchronously, so no Ref is needed.
75
+ const vnodeSlot = { maybeCurrentVNode: Option.none() };
76
+ const patchRuntimeVNode = (maybeCurrentVNode, nextVNode, seen) => {
77
+ try {
78
+ return __patchVNode(maybeCurrentVNode, nextVNode, container, seen, patchedVNode => {
79
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
80
+ });
81
+ }
82
+ catch (error) {
83
+ try {
84
+ const maybeRecoveryVNode = vnodeSlot.maybeCurrentVNode;
85
+ if (Option.isSome(maybeRecoveryVNode)) {
86
+ vnodeSlot.maybeCurrentVNode = Option.some(__recoverVNodeAfterPatchFailure(maybeRecoveryVNode.value));
87
+ }
88
+ }
89
+ finally {
90
+ flushReplayUnmountsAfterPatchFailure();
91
+ }
92
+ throw error;
93
+ }
94
+ };
95
+ // NOTE: consumed by the first render only. Set when this boot found
96
+ // an adoptable server-rendered root; the first patch then goes
97
+ // through `__hydrateVNode` instead of replacing the container.
98
+ let pendingHydrationRoot = Option.getOrNull(maybeHydrationRoot);
99
+ // NOTE: registered before any perpetual fiber is forked so it runs
100
+ // after they are interrupted (scope finalizers are LIFO). Patching to
101
+ // an empty tree fires snabbdom destroy hooks, which is what releases
102
+ // Mounts; swapping the placeholder for the original container leaves
103
+ // the host DOM as it was before the first render, ready for a fresh
104
+ // embed of the same container. Gated on interruption: that is the
105
+ // dispose path. A runtime that stops because it crashed completes
106
+ // normally after rendering the crash view, and the crash view must
107
+ // stay visible.
108
+ yield* Effect.addFinalizer(exit => Effect.gen(function* () {
109
+ if (!Exit.hasInterrupts(exit)) {
110
+ return;
111
+ }
112
+ const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
113
+ yield* Option.match(maybeCurrentVNode, {
114
+ onNone: () => Effect.void,
115
+ onSome: currentVNode => Effect.sync(() => {
116
+ const placeholderNode = __patchVNode(Option.some(currentVNode), null, container).elm;
117
+ if (placeholderNode && placeholderNode.parentNode) {
118
+ placeholderNode.parentNode.replaceChild(container, placeholderNode);
119
+ container.replaceChildren();
120
+ }
121
+ }),
122
+ });
123
+ }));
124
+ // NOTE: shared by every crash path: the init render, the plain
125
+ // message drain and render frame (which reach it through
126
+ // `Effect.runFork` from their catch blocks), and the Command and
127
+ // Subscription fibers (a Command's Effect and a Subscription's
128
+ // Stream are typed with a `never` error channel, so a cause
129
+ // escaping one can only be a `resources` Layer build failure or an
130
+ // escaped defect, both unrecoverable). Each path catches its own
131
+ // cause so a failure surfaces as the crash view instead of dying
132
+ // silently and leaving the DOM frozen at the last successful
133
+ // render. The first crash wins: concurrent Command fibers can fail
134
+ // on the same broken Layer, and only one should report and render.
135
+ const crashWith = (cause, maybeMessage) => Effect.sync(() => {
136
+ if (status.isCrashed) {
137
+ return;
138
+ }
139
+ status.isCrashed = true;
140
+ // NOTE: the crash view should appear at once, not animate in from
141
+ // a snapshot of the state that crashed.
142
+ skipPendingViewTransition();
143
+ const model = readLiveModel();
144
+ const squashed = Cause.squash(cause);
145
+ const error = squashed instanceof Error ? squashed : new Error(String(squashed));
146
+ renderCrashView({ error, model, message: maybeMessage }, crash, container, vnodeSlot, manageDocument);
147
+ settlePendingCommit();
148
+ });
149
+ // NOTE: `maybeLastDirtyMessage` holds the most recent dirtying Message,
150
+ // so slow render-phase callbacks during high-rate bursts attribute to
151
+ // the last Message in the frame batch, not the specific one that pushed
152
+ // the view past threshold. Acceptable for a debug callback; full
153
+ // attribution would require correlating each message with its render
154
+ // contribution, which isn't worth the complexity.
155
+ let maybeLastDirtyMessage = Option.none();
156
+ const setLastDirtyMessage = (message) => {
157
+ maybeLastDirtyMessage = Option.some(message);
158
+ };
159
+ const dispatchSync = (message) => {
160
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
161
+ enqueueMessage(message);
162
+ };
163
+ const dispatchAsync = (message) =>
164
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
165
+ enqueueMessageEffect(message);
166
+ const dispatch = { dispatchAsync, dispatchSync };
167
+ // NOTE: One boundary registry per runtime instance, shared
168
+ // across renders so Submodel wrap descriptors registered by
169
+ // h.submodel persist between renders. The render function calls
170
+ // `beginHtmlRender` at the start of each pass; wraps for
171
+ // unmounted Submodels (e.g. an entry removed from a list) are
172
+ // dropped from the registry via snabbdom destroy hooks attached
173
+ // by `h.submodel` to each child vnode.
174
+ const boundaryRegistry = createHtmlBoundaryRegistry();
175
+ // NOTE: callers set `isRenderingFrame` before calling this and clear
176
+ // it after. Without it, a Message dispatched while the patch is still
177
+ // running would run update against a half-patched DOM. For a replay,
178
+ // `render` also calls `beginReplayHtmlRender` first, so the old
179
+ // tree's unmount callbacks don't dispatch into live history.
180
+ const renderSync = (model, maybeMessage, dispatchService, renderContext, renderMode) => {
181
+ const maybeLiveRender = Option.liftPredicate(renderMode, mode => mode === 'Live');
182
+ const maybeLiveSlowView = Option.flatMap(maybeLiveRender, () => maybeSlowView);
183
+ const maybeLiveSlowPatch = Option.flatMap(maybeLiveRender, () => maybeSlowPatch);
184
+ const [nextDocument, maybeViewDuration] = measureSlowPhase(maybeLiveSlowView, () => {
185
+ beginHtmlRender(boundaryRegistry);
186
+ setHtmlRuntime(dispatchService.dispatchSync, renderContext, boundaryRegistry, renderMode);
187
+ try {
188
+ return view(model, htmlBuilder);
189
+ }
190
+ finally {
191
+ clearHtmlRuntime();
192
+ }
193
+ });
194
+ const { body: nextVNode } = nextDocument;
195
+ reportSlowPhase(maybeLiveSlowView, maybeViewDuration, (durationMs, thresholdMs) => ({
196
+ _tag: 'View',
197
+ model,
198
+ message: maybeMessage,
199
+ durationMs,
200
+ thresholdMs,
201
+ }));
202
+ const { maybeCurrentVNode } = vnodeSlot;
203
+ const [patchedVNode, maybePatchDuration] = measureSlowPhase(maybeLiveSlowPatch, () => {
204
+ if (Option.isNone(maybeCurrentVNode) &&
205
+ pendingHydrationRoot !== null) {
206
+ const hydrationRoot = pendingHydrationRoot;
207
+ pendingHydrationRoot = null;
208
+ // NOTE: strip the stamp before the patch, not after, so the
209
+ // patch is the sole owner of the root's attributes. It has
210
+ // already served its purpose of locating the root, and
211
+ // removing it after would delete a `data-foldkit-app` the view
212
+ // itself declares, which a later equal-vnode patch would not
213
+ // restore. Removing it here also stops a later boot on the same
214
+ // container (a dispose-then-embed remount) from re-detecting
215
+ // this now-consumed root as hydratable.
216
+ hydrationRoot.removeAttribute(FOLDKIT_APP_ATTRIBUTE);
217
+ // An empty id reaches the adoption step's own check as a
218
+ // value that matches nothing. Boot already refused a
219
+ // hydration without an id, so this stands in only for a
220
+ // caller that reached here another way.
221
+ return __hydrateVNode(hydrationRoot, nextVNode, boundaryRegistry.dedupeSeen, buildId ?? '');
222
+ }
223
+ return patchRuntimeVNode(maybeCurrentVNode, nextVNode, boundaryRegistry.dedupeSeen);
224
+ });
225
+ vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
226
+ reportSlowPhase(maybeLiveSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
227
+ _tag: 'Patch',
228
+ model,
229
+ message: maybeMessage,
230
+ durationMs,
231
+ thresholdMs,
232
+ }));
233
+ if (manageDocument) {
234
+ applyDocumentMetadata(nextDocument, patchedVNode.elm);
235
+ }
236
+ if (import.meta.hot) {
237
+ duplicateIdScanner?.schedule(patchedVNode.elm);
238
+ }
239
+ };
240
+ // NOTE: `dispatchService` defaults to live dispatch but is overridable
241
+ // so a time-travel render can bind both declarative handlers and newly
242
+ // acquired Mounts to `noOpDispatch`. A live Mount keeps its dispatcher
243
+ // across replay, while a replay-created Mount stays muted until a live
244
+ // resume patch releases it and starts the live action. This preserves
245
+ // valid async results from live Mounts without granting a historical
246
+ // acquisition access to the live Model.
247
+ const render = (model, maybeMessage, dispatchService = dispatch, renderMode = 'Live') => Effect.gen(function* () {
248
+ status.isRenderingFrame = true;
249
+ const renderContext = yield* Effect.context();
250
+ if (renderMode === 'Replay') {
251
+ beginReplayHtmlRender();
252
+ }
253
+ renderSync(model, maybeMessage, dispatchService, renderContext, renderMode);
254
+ }).pipe(Effect.ensuring(Effect.sync(() => {
255
+ status.isRenderingFrame = false;
256
+ endReplayHtmlRender();
257
+ drainPendingMessages();
258
+ })), Effect.provideService(Dispatch, dispatchService), Effect.provideService(MountTracker, mountTracker), Effect.provideService(MountRuntime, mountRuntime));
259
+ const liveRenderContext = Context.add(Context.add(Context.add(runtimeContext, Dispatch, dispatch), MountTracker, mountTracker), MountRuntime, mountRuntime);
260
+ // NOTE: the render, Mount drain, DevTools attribution, and
261
+ // patch-time-buffer flush. Shared by the plain path (called directly)
262
+ // and the View Transition path (called from the transition's update
263
+ // callback), which run identical work; only whether they run inside
264
+ // `document.startViewTransition` differs. `isRenderingFrame` gates the
265
+ // buffering of Messages dispatched by patch-time hooks, so it must
266
+ // wrap the actual patch, which on the transition path happens inside
267
+ // the callback, not when the frame is scheduled.
268
+ const runRenderFrameBody = () => {
269
+ status.isRenderingFrame = true;
270
+ // NOTE: captured before the patch, because `drainPendingMessages`
271
+ // below can advance the live Model again before the next frame reads
272
+ // it. What this frame painted is what the next transition animates
273
+ // away from.
274
+ const renderedModel = readLiveModel();
275
+ const isResuming = readViewState() === 'Paused';
276
+ if (isResuming) {
277
+ // NOTE: Messages accumulated while a historical view owned the DOM
278
+ // did not cause this repaint. A Message arriving during the patch
279
+ // can repopulate this field when the buffered queue drains.
280
+ maybeLastDirtyMessage = Option.none();
281
+ }
282
+ try {
283
+ renderSync(readLiveModel(), maybeLastDirtyMessage, dispatch, liveRenderContext, 'Live');
284
+ // NOTE: after the patch, so a render that threw leaves this on the
285
+ // Model still on screen, and before `drainPendingMessages` below,
286
+ // whose handlers can advance the live Model again.
287
+ lastRenderedModel = renderedModel;
288
+ // NOTE: resume clears the store's pause flag before this frame.
289
+ // Publish Live only after the live DOM has been installed.
290
+ setViewState('Live');
291
+ attachRenderedMounts();
292
+ }
293
+ catch (error) {
294
+ Effect.runFork(crashWith(Cause.die(error), maybeLastDirtyMessage));
295
+ }
296
+ finally {
297
+ status.isRenderingFrame = false;
298
+ }
299
+ // NOTE: Messages dispatched by patch-time hooks (for example,
300
+ // OnUnmount destroys, or Mount emissions) were buffered while the
301
+ // frame held the stack; they process now, after the patch has
302
+ // committed and the frame's Mount events are attributed.
303
+ drainPendingMessages();
304
+ // NOTE: last, so a waiter resumed by the commit observes the same
305
+ // DOM and the same processed-Message ordering it saw when
306
+ // `afterCommit` counted frames.
307
+ settlePendingCommit();
308
+ };
309
+ // NOTE: starts a View Transition around this frame's render when the
310
+ // `viewTransition` predicate matches, returning `true` when it did.
311
+ // `startViewTransition` invokes its update callback asynchronously
312
+ // after snapshotting the old DOM, so the callback reads the live Model
313
+ // and `maybeLastDirtyMessage` fresh (the plain loop may have advanced
314
+ // the model while the browser suppressed rendering) and re-checks the
315
+ // disposal and crash guards, which can flip while the transition is
316
+ // pending. The unconfigured path never reaches this function; the
317
+ // `Option.isNone` check in `renderFramePlain` returns first, so a
318
+ // runtime without `viewTransition` allocates no per-frame callback.
319
+ const startFrameViewTransition = (resolved) => {
320
+ if (readViewState() === 'Paused') {
321
+ return false;
322
+ }
323
+ if (resolved.reducedMotionQuery.matches) {
324
+ return false;
325
+ }
326
+ if (Option.isNone(maybeLastDirtyMessage)) {
327
+ return false;
328
+ }
329
+ const maybeDecision = __decideViewTransition(resolved.decide, {
330
+ previousModel: lastRenderedModel,
331
+ model: readLiveModel(),
332
+ message: maybeLastDirtyMessage.value,
333
+ });
334
+ if (Option.isNone(maybeDecision)) {
335
+ return false;
336
+ }
337
+ // NOTE: invalidate an older transition before this one takes
338
+ // ownership of the latest live repaint. The browser may still call
339
+ // the older update callback, but its invalidation guard leaves the
340
+ // DOM and commit notifier to this transition.
341
+ skipPendingViewTransition();
342
+ try {
343
+ const update = {
344
+ isInvalidated: false,
345
+ didRun: false,
346
+ };
347
+ const handle = resolved.startViewTransition(() => {
348
+ if (update.isInvalidated || update.didRun) {
349
+ return;
350
+ }
351
+ update.didRun = true;
352
+ // NOTE: the view state closes the resume window after the
353
+ // store is live but before its plain frame has restored the live
354
+ // DOM. A transition invalidated by jumpTo stays invalid forever,
355
+ // so its callback cannot repaint inside the stale transition
356
+ // even if it arrives after resume has published `Live`.
357
+ if (status.isRuntimeDisposed ||
358
+ status.isCrashed ||
359
+ isPausedNow() ||
360
+ readViewState() === 'Paused') {
361
+ settlePendingCommit();
362
+ return;
363
+ }
364
+ runRenderFrameBody();
365
+ }, maybeDecision.value.maybeTypes);
366
+ maybePendingViewTransition = Option.some({ handle, update });
367
+ __silenceViewTransitionRejections(handle);
368
+ return true;
369
+ }
370
+ catch {
371
+ // NOTE: an escaping throw would leave the rAF callback without a
372
+ // patch and without settling the commit notifier, parking every
373
+ // `Render.afterCommit` on this frame forever.
374
+ return false;
375
+ }
376
+ };
377
+ // NOTE: every path out of a scheduled frame settles the commit
378
+ // notifier, whether or not it patched. A frame abandoned silently
379
+ // would strand any `Render.afterCommit` registered against it, and
380
+ // the Dom helpers that gate on it would never run their DOM work.
381
+ const renderFramePlain = () => {
382
+ isRenderFrameScheduled = false;
383
+ const isRestoringLiveView = isLiveViewRestorePending;
384
+ isLiveViewRestorePending = false;
385
+ // NOTE: a frame scheduled before disposal fires after it; a
386
+ // disposed runtime must not repaint the released container.
387
+ if (status.isRuntimeDisposed) {
388
+ settlePendingCommit();
389
+ return;
390
+ }
391
+ // NOTE: a frame is running, so the browser got control back; the
392
+ // drain budget starts fresh.
393
+ resetDrainBudget();
394
+ // NOTE: a Message that dirtied the model can also be the one
395
+ // whose Command crashed the runtime. Without this guard the
396
+ // next animation frame would render the live view over the
397
+ // crash view.
398
+ if (status.isCrashed) {
399
+ settlePendingCommit();
400
+ return;
401
+ }
402
+ if (isPausedNow()) {
403
+ settlePendingCommit();
404
+ return;
405
+ }
406
+ if (readViewState() === 'Paused' && !isRestoringLiveView) {
407
+ settlePendingCommit();
408
+ return;
409
+ }
410
+ // NOTE: the unconfigured path pays one `Option.isNone` check and
411
+ // renders directly, allocating no per-frame callback. Only a
412
+ // runtime configured with `viewTransition` reaches
413
+ // `startFrameViewTransition`, which decides per frame whether to
414
+ // wrap the render in `document.startViewTransition`. When it does,
415
+ // the render runs later, inside the transition's update callback.
416
+ if (Option.isNone(maybeResolvedViewTransition)) {
417
+ runRenderFrameBody();
418
+ return;
419
+ }
420
+ if (!startFrameViewTransition(maybeResolvedViewTransition.value)) {
421
+ runRenderFrameBody();
422
+ }
423
+ };
424
+ // NOTE: render frames run as plain JavaScript inside the
425
+ // requestAnimationFrame callback. Messages arriving between frames mark
426
+ // at most one pending frame; the callback renders once with the latest
427
+ // model.
428
+ const scheduleRenderFrame = (isRestoringLiveView = false) => {
429
+ if (isRestoringLiveView) {
430
+ isLiveViewRestorePending = true;
431
+ }
432
+ if (isRenderFrameScheduled) {
433
+ return;
434
+ }
435
+ isRenderFrameScheduled = true;
436
+ commitNotifier.markCommitPending();
437
+ requestAnimationFrame(renderFramePlain);
438
+ };
439
+ const devToolsRenderBridge = {
440
+ // NOTE: passes `noOpDispatch` so declarative handlers and Mounts
441
+ // acquired by the replay cannot reach the live Model. Their
442
+ // fibers stay alive and can observe view-state changes while the
443
+ // historical view owns them. If resume reuses such an element,
444
+ // OnMount releases the replay acquisition before starting the
445
+ // live action. Also discards mount events fired during the render
446
+ // so they don't get attributed to the next user-initiated dispatch.
447
+ renderReplay: model => Effect.gen(function* () {
448
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
449
+ const replayedModel = model;
450
+ const previousRenderedModel = lastRenderedModel;
451
+ // NOTE: a Mount surviving from the live view must observe
452
+ // Paused before the historical patch can expose different
453
+ // DOM. Mounts inserted by that patch capture this state when
454
+ // acquired, so asynchronous setup cannot skip Paused even if
455
+ // it consumes the Stream only after the live view returns.
456
+ setViewState('Paused');
457
+ // NOTE: a transition still animating belongs to the live
458
+ // state this replay is about to paint over. Left running it
459
+ // animates a dead snapshot across the replayed DOM.
460
+ skipPendingViewTransition();
461
+ const replayRenderExit = yield* Effect.exit(render(replayedModel, Option.none(), noOpDispatch, 'Replay'));
462
+ if (Exit.isFailure(replayRenderExit)) {
463
+ drainMountEvents();
464
+ if (isPausedNow()) {
465
+ // NOTE: the failed patch may already have changed the
466
+ // DOM. Repaint the Model at the store's previous paused
467
+ // index before returning the failure. If that Model no
468
+ // longer renders either, resume the store so its normal
469
+ // live frame becomes the single recovery path.
470
+ const rollbackExit = yield* Effect.exit(render(previousRenderedModel, Option.none(), noOpDispatch, 'Replay'));
471
+ drainMountEvents();
472
+ if (Exit.isFailure(rollbackExit)) {
473
+ yield* resumeDevTools;
474
+ }
475
+ }
476
+ else {
477
+ // NOTE: a failed first jump leaves the store live. Keep
478
+ // Mounts Paused through the recovery patch, which
479
+ // publishes Live only after the live DOM is restored.
480
+ yield* Effect.sync(() => scheduleRenderFrame(true));
481
+ }
482
+ return yield* Effect.failCause(replayRenderExit.cause);
483
+ }
484
+ drainMountEvents();
485
+ // NOTE: a replay paints a past Model, so it owns the DOM on
486
+ // screen until the next live frame. Leaving
487
+ // `lastRenderedModel` on the pre-pause Model would hand the
488
+ // `viewTransition` predicate a `previousModel` describing a
489
+ // DOM that no longer exists, and the frame `resume`
490
+ // schedules would animate the wrong direction out of the
491
+ // wrong snapshot.
492
+ lastRenderedModel = replayedModel;
493
+ // NOTE: the Message that dirtied the pre-pause frame does not
494
+ // describe this repaint. Clearing it means the frame `resume`
495
+ // schedules renders plainly, matching the documented rule
496
+ // that time-travel never animates.
497
+ maybeLastDirtyMessage = Option.none();
498
+ }),
499
+ // NOTE: `resume` calls this after a jumpTo render attached DOM
500
+ // listeners to `noOpDispatch`. Scheduling a frame renders the
501
+ // live model with live dispatch and rebinds listeners.
502
+ markRenderPending: Effect.sync(() => scheduleRenderFrame(true)),
503
+ };
504
+ return {
505
+ crashWith,
506
+ render,
507
+ setLastDirtyMessage,
508
+ scheduleRenderFrame,
509
+ skipPendingViewTransition,
510
+ devToolsRenderBridge,
511
+ };
512
+ });
@@ -0,0 +1,33 @@
1
+ import { Effect, Layer, Option, Ref, type Scope } from 'effect';
2
+ import type { ManagedResourceConfig, ManagedResources } from '../managedResource/index.js';
3
+ import type { PortChannelsBundle } from './hostConnector.js';
4
+ /** A Managed Resource's config next to the Ref that holds its acquired value. */
5
+ export type ManagedResourceRef<Model, Message> = Readonly<{
6
+ config: ManagedResourceConfig<Model, Message>;
7
+ ref: Ref.Ref<Option.Option<unknown>>;
8
+ }>;
9
+ /**
10
+ * The functions the runtime uses to give Commands and Subscriptions the
11
+ * app's `resources` Layer, its Managed Resources, its port channels, and
12
+ * the interrupt registry, and to give Flags the Layer alone, plus the
13
+ * Managed Resource refs the lifecycle fibers write to.
14
+ */
15
+ export type ResourceProvider<Model, Message, Resources, ManagedResourceServices> = Readonly<{
16
+ managedResourceRefs: ReadonlyArray<ManagedResourceRef<Model, Message>>;
17
+ provideAllResources: <A>(effect: Effect.Effect<A, never, Resources | ManagedResourceServices>) => Effect.Effect<A>;
18
+ provideResources: <A>(effect: Effect.Effect<A, never, Resources>) => Effect.Effect<A>;
19
+ }>;
20
+ /**
21
+ * Builds the runtime's resource provider: one cached build of the
22
+ * `resources` Layer into the runtime scope, a Ref per Managed Resource, and
23
+ * the two functions that provide them. `provideAllResources` is for Commands
24
+ * and Subscriptions. `provideResources` is for Flags, which run before there
25
+ * is a Model to render a crash view against.
26
+ */
27
+ export declare const makeResourceProvider: <Model, Message, Resources, ManagedResourceServices>({ resources, managedResources, runtimeScope, maybePortChannels, }: Readonly<{
28
+ resources: Layer.Layer<Resources> | undefined;
29
+ managedResources: ManagedResources<Model, Message, ManagedResourceServices> | undefined;
30
+ runtimeScope: Scope.Scope;
31
+ maybePortChannels: Option.Option<PortChannelsBundle>;
32
+ }>) => Effect.Effect<ResourceProvider<Model, Message, Resources, ManagedResourceServices>>;
33
+ //# sourceMappingURL=resourceProvider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resourceProvider.d.ts","sourceRoot":"","sources":["../../src/runtime/resourceProvider.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,MAAM,EACN,KAAK,EACL,MAAM,EAEN,GAAG,EACH,KAAK,KAAK,EACX,MAAM,QAAQ,CAAA;AAMf,OAAO,KAAK,EACV,qBAAqB,EACrB,gBAAgB,EACjB,MAAM,6BAA6B,CAAA;AAEpC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAE5D,iFAAiF;AACjF,MAAM,MAAM,kBAAkB,CAAC,KAAK,EAAE,OAAO,IAAI,QAAQ,CAAC;IACxD,MAAM,EAAE,qBAAqB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;IAC7C,GAAG,EAAE,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAA;CACrC,CAAC,CAAA;AAEF;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,CAC1B,KAAK,EACL,OAAO,EACP,SAAS,EACT,uBAAuB,IACrB,QAAQ,CAAC;IACX,mBAAmB,EAAE,aAAa,CAAC,kBAAkB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,CAAA;IACtE,mBAAmB,EAAE,CAAC,CAAC,EACrB,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,EAAE,SAAS,GAAG,uBAAuB,CAAC,KACjE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IACrB,gBAAgB,EAAE,CAAC,CAAC,EAClB,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,EAAE,SAAS,CAAC,KACvC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;CACtB,CAAC,CAAA;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAC/B,KAAK,EACL,OAAO,EACP,SAAS,EACT,uBAAuB,qEAMtB,QAAQ,CAAC;IACV,SAAS,EAAE,KAAK,CAAC,KAAK,CAAC,SAAS,CAAC,GAAG,SAAS,CAAA;IAC7C,gBAAgB,EACd,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,uBAAuB,CAAC,GAAG,SAAS,CAAA;IACvE,YAAY,EAAE,KAAK,CAAC,KAAK,CAAA;IACzB,iBAAiB,EAAE,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAA;CACrD,CAAC,KAAG,MAAM,CAAC,MAAM,CAChB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,uBAAuB,CAAC,CAmKlE,CAAA"}
@@ -0,0 +1,98 @@
1
+ import { Array, Cause, Context, Effect, Layer, Option, Record, Ref, } from 'effect';
2
+ import { __CurrentRegistry as __CurrentInterruptRegistry, __makeRegistry as __makeInterruptRegistry, } from '../command/interruptible/index.js';
3
+ import { __CurrentPortChannels } from '../port/index.js';
4
+ /**
5
+ * Builds the runtime's resource provider: one cached build of the
6
+ * `resources` Layer into the runtime scope, a Ref per Managed Resource, and
7
+ * the two functions that provide them. `provideAllResources` is for Commands
8
+ * and Subscriptions. `provideResources` is for Flags, which run before there
9
+ * is a Model to render a crash view against.
10
+ */
11
+ export const makeResourceProvider = ({ resources, managedResources, runtimeScope, maybePortChannels, }) => Effect.gen(function* () {
12
+ // NOTE: `Effect.provide(effect, layer)` builds the Layer into a
13
+ // scope that closes when the provided effect ends, so providing the
14
+ // Layer per Command would construct and tear down every resource on
15
+ // each invocation. Building once into `runtimeScope` through a
16
+ // cached Effect is what makes `resources` long-lived: the first
17
+ // Command or Subscription that runs triggers construction, every
18
+ // later one shares the same built services, and release happens at
19
+ // runtime teardown. The build is uninterruptible because
20
+ // `Effect.cached` caches whatever Exit the first run produces:
21
+ // dispose racing an in-flight build would otherwise cache an
22
+ // interrupt, which every waiter would then surface as a crash.
23
+ const maybeAcquireResourceContext = yield* Option.match(Option.fromNullishOr(resources), {
24
+ onNone: () => Effect.succeed(Option.none()),
25
+ onSome: resourceLayer => Effect.map(Effect.cached(Effect.uninterruptible(Layer.buildWithScope(resourceLayer, runtimeScope))), Option.some),
26
+ });
27
+ const managedResourceEntries = managedResources
28
+ ? /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
29
+ Record.toEntries(managedResources)
30
+ : [];
31
+ const managedResourceRefs = yield* Effect.forEach(managedResourceEntries, ([_key, config]) => Ref.make(Option.none()).pipe(Effect.map(ref => ({ config, ref }))));
32
+ const mergeResourceIntoLayer = (layer, { config, ref }) => Layer.merge(layer, Layer.succeed(
33
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
34
+ config.resource._tag, ref));
35
+ const maybeManagedResourceLayer = Array.match(managedResourceRefs, {
36
+ onEmpty: () => Option.none(),
37
+ onNonEmpty: refs => Option.some(Array.reduce(refs,
38
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
39
+ Layer.empty, mergeResourceIntoLayer)),
40
+ });
41
+ const interruptRegistry = __makeInterruptRegistry();
42
+ const provideAllResources = (effect) => {
43
+ const withResources = Option.match(maybeAcquireResourceContext, {
44
+ onNone: () => effect,
45
+ onSome: acquireResourceContext => Effect.flatMap(acquireResourceContext, resourceContext => Effect.provideContext(effect, resourceContext)),
46
+ });
47
+ const withManagedResources = Option.match(maybeManagedResourceLayer, {
48
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
49
+ onNone: () => withResources,
50
+ onSome: managedLayer =>
51
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
52
+ Effect.provide(withResources, managedLayer),
53
+ });
54
+ const withPortChannels = Option.match(maybePortChannels, {
55
+ onNone: () => withManagedResources,
56
+ onSome: portChannels => Effect.provideService(withManagedResources, __CurrentPortChannels, portChannels.channels),
57
+ });
58
+ return Effect.provideService(withPortChannels, __CurrentInterruptRegistry, interruptRegistry);
59
+ };
60
+ // NOTE: Flags run through the same cached build that Commands and
61
+ // Subscriptions use, rather than being handed the Layer again, so a
62
+ // service needed both at startup and by a Command is constructed
63
+ // once. An app without Flags never reaches it, which keeps the Layer
64
+ // lazy when the first thing that needs it is a Command.
65
+ //
66
+ // NOTE: a Layer that fails to build is not fatal here. Flags resolve
67
+ // before `init`, so there is no Model for a crash view to render
68
+ // against and a failure escaping this point kills the app with a
69
+ // blank container. Running Flags against an empty context instead
70
+ // lets an app whose Flags never touch the Layer boot as it did
71
+ // before Flags could consume `resources`: the cached failure then
72
+ // surfaces at the first Command or Subscription, where `crashWith`
73
+ // does render the crash view. Flags that do need the Layer still
74
+ // fail here, and both causes are reported: the `Service not found`
75
+ // defect the empty context produced is useless on its own, and the
76
+ // build failure that explains it would be lost if it replaced the
77
+ // Flags cause outright. Combining them also keeps a Flags Effect
78
+ // that fails for its own unrelated reason visible instead of
79
+ // attributing its defect to the Layer. Interrupts propagate
80
+ // untouched on both sides, because dispose racing either the build
81
+ // or the Flags run is not a failure to recover from, and
82
+ // `Effect.catchCause` hands the handler interrupt causes too.
83
+ const provideResources = (effect) => Option.match(maybeAcquireResourceContext, {
84
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
85
+ onNone: () => effect,
86
+ onSome: acquireResourceContext => Effect.matchCauseEffect(acquireResourceContext, {
87
+ onFailure: buildCause => Cause.hasInterruptsOnly(buildCause)
88
+ ? Effect.failCause(buildCause)
89
+ : Effect.catchCause(Effect.provideContext(effect,
90
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
91
+ Context.empty()), flagsCause => Cause.hasInterruptsOnly(flagsCause)
92
+ ? Effect.failCause(flagsCause)
93
+ : Effect.failCause(Cause.combine(buildCause, flagsCause))),
94
+ onSuccess: resourceContext => Effect.provideContext(effect, resourceContext),
95
+ }),
96
+ });
97
+ return { managedResourceRefs, provideAllResources, provideResources };
98
+ });