foldkit 0.106.0 → 0.108.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 (41) hide show
  1. package/README.md +5 -4
  2. package/dist/devTools/overlay.d.ts +1 -1
  3. package/dist/devTools/overlay.d.ts.map +1 -1
  4. package/dist/devTools/overlay.js +11 -12
  5. package/dist/devTools/webSocketBridge.d.ts +2 -2
  6. package/dist/devTools/webSocketBridge.d.ts.map +1 -1
  7. package/dist/devTools/webSocketBridge.js +9 -0
  8. package/dist/html/index.d.ts +5 -1
  9. package/dist/html/index.d.ts.map +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/port/index.d.ts +2 -0
  14. package/dist/port/index.d.ts.map +1 -0
  15. package/dist/port/index.js +1 -0
  16. package/dist/port/port.d.ts +143 -0
  17. package/dist/port/port.d.ts.map +1 -0
  18. package/dist/port/port.js +156 -0
  19. package/dist/port/public.d.ts +3 -0
  20. package/dist/port/public.d.ts.map +1 -0
  21. package/dist/port/public.js +1 -0
  22. package/dist/runtime/browserListeners.d.ts +3 -3
  23. package/dist/runtime/browserListeners.d.ts.map +1 -1
  24. package/dist/runtime/browserListeners.js +23 -5
  25. package/dist/runtime/crashUI.d.ts.map +1 -1
  26. package/dist/runtime/crashUI.js +1 -3
  27. package/dist/runtime/public.d.ts +2 -2
  28. package/dist/runtime/public.d.ts.map +1 -1
  29. package/dist/runtime/public.js +1 -1
  30. package/dist/runtime/runtime.d.ts +172 -28
  31. package/dist/runtime/runtime.d.ts.map +1 -1
  32. package/dist/runtime/runtime.js +462 -62
  33. package/dist/runtime/subscription.d.ts +6 -1
  34. package/dist/runtime/subscription.d.ts.map +1 -1
  35. package/dist/ui/dragAndDrop/index.d.ts +212 -246
  36. package/dist/ui/dragAndDrop/index.d.ts.map +1 -1
  37. package/dist/ui/slider/index.d.ts +124 -179
  38. package/dist/ui/slider/index.d.ts.map +1 -1
  39. package/dist/ui/virtualList/index.d.ts +28 -38
  40. package/dist/ui/virtualList/index.d.ts.map +1 -1
  41. package/package.json +5 -1
@@ -1,11 +1,12 @@
1
1
  import { BrowserRuntime } from '@effect/platform-browser';
2
- import { Array, Cause, Context, Duration, Effect, Exit, Function, Layer, Match, Option, Predicate, PubSub, Queue, Record, Ref, Scheduler, Schema, Stream, SubscriptionRef, pipe, } from 'effect';
2
+ import { Array, Cause, Context, Duration, Effect, Exit, Fiber, Function, Layer, Match, Option, Predicate, PubSub, Queue, Record, Ref, Scheduler, Schema, Stream, SubscriptionRef, pipe, } from 'effect';
3
3
  import { h } from 'snabbdom';
4
4
  import { createOverlay } from '../devTools/overlay.js';
5
5
  import { createDevToolsStore, } from '../devTools/store.js';
6
6
  import { startWebSocketBridge } from '../devTools/webSocketBridge.js';
7
7
  import { __beginRender as beginHtmlRender, __clearRuntime as clearHtmlRuntime, __createBoundaryRegistry as createHtmlBoundaryRegistry, __setRuntime as setHtmlRuntime, } from '../html/index.js';
8
8
  import { MountTracker } from '../mount/index.js';
9
+ import { __CurrentPortChannels, __makeInboundChannel, } from '../port/index.js';
9
10
  import { fromString as urlFromString } from '../url/index.js';
10
11
  import { dedupeSharedVNodes, patch, toVNode } from '../vdom.js';
11
12
  import { addBfcacheRestoreListener, addNavigationEventListeners, } from './browserListeners.js';
@@ -48,7 +49,130 @@ const defaultSlowViewCallback = (context) => {
48
49
  /** Effect service tag that provides message dispatching to the view layer. */
49
50
  export class Dispatch extends Context.Service()('@foldkit/Dispatch') {
50
51
  }
51
- const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscriptions, container, routing: routingConfig, crash, slowView, freezeModel, resources, managedResources, devTools, }) => {
52
+ const makeHostConnector = () => {
53
+ let isDisposed = false;
54
+ let maybeDeliverInbound = Option.none();
55
+ const pendingInboundSends = [];
56
+ const listenersByPort = new Map();
57
+ const sendInbound = (portName, port, value) => {
58
+ if (isDisposed) {
59
+ return Exit.void;
60
+ }
61
+ const decodeExit = Schema.decodeUnknownExit(port.schema)(value);
62
+ Exit.match(decodeExit, {
63
+ onFailure: cause => {
64
+ console.error(`[foldkit] Inbound port "${portName}" rejected a value:`, Cause.squash(cause));
65
+ },
66
+ onSuccess: decodedValue => {
67
+ Option.match(maybeDeliverInbound, {
68
+ onNone: () => {
69
+ pendingInboundSends.push({ port, value: decodedValue });
70
+ },
71
+ onSome: deliverInbound => deliverInbound(port, decodedValue),
72
+ });
73
+ },
74
+ });
75
+ return Exit.asVoid(decodeExit);
76
+ };
77
+ const addListener = (port, listener) => {
78
+ if (isDisposed) {
79
+ return Function.constVoid;
80
+ }
81
+ const listeners = listenersByPort.get(port) ?? new Set();
82
+ listenersByPort.set(port, listeners);
83
+ listeners.add(listener);
84
+ return () => {
85
+ listeners.delete(listener);
86
+ };
87
+ };
88
+ // NOTE: delivery is deferred to a microtask so a host listener never runs
89
+ // inside the runtime's Command fiber (a listener that synchronously calls
90
+ // send or dispose must not re-enter the runtime), and so a host that
91
+ // subscribes synchronously right after embed() returns still receives
92
+ // emissions from init Commands.
93
+ const deliverOutbound = (port, encodedValue) => {
94
+ if (isDisposed) {
95
+ return;
96
+ }
97
+ queueMicrotask(() => {
98
+ if (isDisposed) {
99
+ return;
100
+ }
101
+ const listeners = listenersByPort.get(port) ?? new Set();
102
+ listeners.forEach(listener => {
103
+ try {
104
+ listener(encodedValue);
105
+ }
106
+ catch (listenerError) {
107
+ console.error('[foldkit] An outbound port listener threw:', listenerError);
108
+ }
109
+ });
110
+ });
111
+ };
112
+ const bind = (deliverInbound) => {
113
+ maybeDeliverInbound = Option.some(deliverInbound);
114
+ const flushedSends = pendingInboundSends.splice(0);
115
+ flushedSends.forEach(({ port, value }) => deliverInbound(port, value));
116
+ };
117
+ const unbind = () => {
118
+ maybeDeliverInbound = Option.none();
119
+ };
120
+ const dispose = () => {
121
+ isDisposed = true;
122
+ pendingInboundSends.length = 0;
123
+ listenersByPort.forEach(listeners => listeners.clear());
124
+ listenersByPort.clear();
125
+ };
126
+ return { sendInbound, addListener, deliverOutbound, bind, unbind, dispose };
127
+ };
128
+ const makePortChannels = (ports, maybeConnector) => {
129
+ const inboundChannelsByPort = new Map();
130
+ Object.values(ports.inbound ?? {}).forEach(port => {
131
+ inboundChannelsByPort.set(port, __makeInboundChannel());
132
+ });
133
+ const outboundPorts = new Set(Object.values(ports.outbound ?? {}));
134
+ const channels = {
135
+ isConfigured: true,
136
+ lookupInbound: port => Option.fromNullishOr(inboundChannelsByPort.get(port)),
137
+ lookupOutbound: port => outboundPorts.has(port)
138
+ ? Option.some(encodedValue => Option.match(maybeConnector, {
139
+ onNone: Function.constVoid,
140
+ onSome: connector => connector.deliverOutbound(port, encodedValue),
141
+ }))
142
+ : Option.none(),
143
+ };
144
+ const deliverInbound = (port, value) => {
145
+ Option.match(Option.fromNullishOr(inboundChannelsByPort.get(port)), {
146
+ onNone: Function.constVoid,
147
+ onSome: channel => channel.deliver(value),
148
+ });
149
+ };
150
+ return { channels, deliverInbound };
151
+ };
152
+ const validatePorts = (ports) => {
153
+ const inboundEntries = Object.entries(ports.inbound ?? {});
154
+ const outboundEntries = Object.entries(ports.outbound ?? {});
155
+ const inboundNames = new Set(inboundEntries.map(([name]) => name));
156
+ outboundEntries.forEach(([name]) => {
157
+ if (inboundNames.has(name)) {
158
+ throw new Error(`[foldkit] Port name "${name}" appears in both inbound and outbound. ` +
159
+ 'Port names share one namespace on the EmbedHandle, so each name ' +
160
+ 'must be unique across both records.');
161
+ }
162
+ });
163
+ const seenPorts = new Set();
164
+ const allEntries = [...inboundEntries, ...outboundEntries];
165
+ allEntries.forEach(([name, port]) => {
166
+ if (seenPorts.has(port)) {
167
+ throw new Error(`[foldkit] The Port registered as "${name}" is also registered under ` +
168
+ 'another name. Each entry in the ports record needs its own ' +
169
+ 'Port.inbound or Port.outbound value.');
170
+ }
171
+ seenPorts.add(port);
172
+ });
173
+ };
174
+ const runtimeInternals = new WeakMap();
175
+ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, manageDocument, subscriptions, container, routing: routingConfig, crash, slowView, freezeModel, resources, managedResources, devTools, }) => {
52
176
  const resolvedSlowView = pipe(slowView ?? {}, Option.liftPredicate(config => config !== false), Option.filter(config => Match.value(config.show ?? DEFAULT_SLOW_VIEW_SHOW).pipe(Match.when('Always', () => true), Match.when('Development', () => !!import.meta.hot), Match.exhaustive)), Option.map(config => ({
53
177
  thresholdMs: config.thresholdMs ?? DEFAULT_SLOW_VIEW_THRESHOLD_MS,
54
178
  onSlowView: config.onSlowView ?? defaultSlowViewCallback,
@@ -60,6 +184,9 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
60
184
  }));
61
185
  const devToolsMaxEntries = pipe(devTools ?? {}, Option.liftPredicate(config => config !== false), Option.flatMapNullishOr(config => config.maxEntries), Option.map(value => Math.max(DEV_TOOLS_MAX_ENTRIES_MIN, Math.min(DEV_TOOLS_MAX_ENTRIES_MAX, value))), Option.getOrUndefined);
62
186
  const maybeFreezeModel = (model) => isFreezeModelActive ? deepFreeze(model) : model;
187
+ if (Predicate.isNotUndefined(ports)) {
188
+ validatePorts(ports);
189
+ }
63
190
  const runtimeId = container?.id ?? '';
64
191
  // NOTE: When the message queue drains a chain of dispatches (e.g. recursive
65
192
  // Commands, websocket bursts), processing all of them inside one macrotask
@@ -77,11 +204,17 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
77
204
  const handle = requestAnimationFrame(() => resume(Effect.void));
78
205
  return Effect.sync(() => cancelAnimationFrame(handle));
79
206
  });
80
- const start = (hmrModel) => Effect.scoped(Effect.gen(function* () {
207
+ const startWith = (maybeConnector, hmrModel) => Effect.scoped(Effect.gen(function* () {
81
208
  if (runtimeId === '') {
82
209
  return yield* Effect.die(new Error('[foldkit] Runtime container must have an `id` for HMR model preservation. ' +
83
- 'Set `container.id = "app"` (or any unique string) before passing it to makeProgram.'));
210
+ 'Set `container.id = "app"` (or any unique string) before passing it to makeApplication or makeElement.'));
84
211
  }
212
+ // NOTE: every perpetual fiber (render loop, Subscription streams,
213
+ // ManagedResource lifecycles) and every Command fiber forks into the
214
+ // runtime scope, so interrupting the runtime fiber (what dispose
215
+ // does) interrupts them all and runs their finalizers. A detached
216
+ // fork would outlive the runtime.
217
+ const runtimeScope = yield* Effect.scope;
85
218
  // NOTE: one persistent MessageChannel for the runtime lifetime,
86
219
  // shared by every burst-budget yield. The queue-drain fiber is the
87
220
  // sole consumer, so a single `pendingYieldResume` slot is sufficient.
@@ -106,7 +239,29 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
106
239
  }
107
240
  });
108
241
  });
109
- const maybeResourceLayer = Option.fromNullishOr(resources);
242
+ // NOTE: `Effect.provide(effect, layer)` builds the Layer into a
243
+ // scope that closes when the provided effect ends, so providing the
244
+ // Layer per Command would construct and tear down every resource on
245
+ // each invocation. Building once into `runtimeScope` through a
246
+ // cached Effect is what makes `resources` long-lived: the first
247
+ // Command or Subscription that runs triggers construction, every
248
+ // later one shares the same built services, and release happens at
249
+ // runtime teardown. The build is uninterruptible because
250
+ // `Effect.cached` caches whatever Exit the first run produces:
251
+ // dispose racing an in-flight build would otherwise cache an
252
+ // interrupt, which every waiter would then surface as a crash.
253
+ const maybeAcquireResourceContext = yield* Option.match(Option.fromNullishOr(resources), {
254
+ onNone: () => Effect.succeed(Option.none()),
255
+ onSome: resourceLayer => Effect.map(Effect.cached(Effect.uninterruptible(Layer.buildWithScope(resourceLayer, runtimeScope))), Option.some),
256
+ });
257
+ const maybePortChannels = pipe(Option.fromNullishOr(ports), Option.map(portsConfig => makePortChannels(portsConfig, maybeConnector)));
258
+ yield* Option.match(Option.all({
259
+ connector: maybeConnector,
260
+ portChannels: maybePortChannels,
261
+ }), {
262
+ onNone: () => Effect.void,
263
+ onSome: ({ connector, portChannels }) => Effect.acquireRelease(Effect.sync(() => connector.bind(portChannels.deliverInbound)), () => Effect.sync(() => connector.unbind())),
264
+ });
110
265
  // NOTE: One boundary registry per runtime instance, shared
111
266
  // across renders so Submodel wrap descriptors registered by
112
267
  // h.submodel persist between renders. The render function calls
@@ -130,17 +285,21 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
130
285
  Layer.empty, mergeResourceIntoLayer)),
131
286
  });
132
287
  const provideAllResources = (effect) => {
133
- const withResources = Option.match(maybeResourceLayer, {
288
+ const withResources = Option.match(maybeAcquireResourceContext, {
134
289
  onNone: () => effect,
135
- onSome: resourceLayer => Effect.provide(effect, resourceLayer),
290
+ onSome: acquireResourceContext => Effect.flatMap(acquireResourceContext, resourceContext => Effect.provideContext(effect, resourceContext)),
136
291
  });
137
- return Option.match(maybeManagedResourceLayer, {
292
+ const withManagedResources = Option.match(maybeManagedResourceLayer, {
138
293
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
139
294
  onNone: () => withResources,
140
295
  onSome: managedLayer =>
141
296
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
142
297
  Effect.provide(withResources, managedLayer),
143
298
  });
299
+ return Option.match(maybePortChannels, {
300
+ onNone: () => withManagedResources,
301
+ onSome: portChannels => Effect.provideService(withManagedResources, __CurrentPortChannels, portChannels.channels),
302
+ });
144
303
  };
145
304
  const flags = yield* resolveFlags;
146
305
  const ModelJsonCodec = Schema.toCodecJson(
@@ -198,26 +357,62 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
198
357
  : init(flags, Option.getOrUndefined(currentUrl));
199
358
  const initModel = maybeFreezeModel(initModelRaw);
200
359
  const modelPubSub = yield* PubSub.unbounded();
201
- yield* Effect.forEach(
202
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
203
- initCommands, command => Effect.forkDetach(command.effect.pipe(Effect.withSpan(command.name, {
204
- attributes: command.args ?? {},
205
- }), provideAllResources, Effect.flatMap(enqueueNormal))));
206
360
  if (routingConfig) {
207
- addNavigationEventListeners(enqueueHighUnsafe, routingConfig);
361
+ yield* Effect.acquireRelease(Effect.sync(() => addNavigationEventListeners(enqueueHighUnsafe, routingConfig)), removeNavigationEventListeners => Effect.sync(() => removeNavigationEventListeners()));
208
362
  }
209
363
  const modelRef = yield* Ref.make(initModel);
210
364
  const maybeCurrentVNodeRef = yield* Ref.make(Option.none());
211
- // NOTE: shared by every perpetual fiber's crash path (init render,
212
- // render loop, message drain). Each fiber catches its own cause so a
213
- // failure surfaces as the crash view instead of dying silently and
214
- // leaving the DOM frozen at the last successful render.
365
+ // NOTE: registered before any perpetual fiber is forked so it runs
366
+ // after they are interrupted (scope finalizers are LIFO). Patching to
367
+ // an empty tree fires snabbdom destroy hooks, which is what releases
368
+ // Mounts; swapping the placeholder for the original container leaves
369
+ // the host DOM as it was before the first render, ready for a fresh
370
+ // embed of the same container. Gated on interruption: that is the
371
+ // dispose path. A runtime that stops because it crashed completes
372
+ // normally after rendering the crash view, and the crash view must
373
+ // stay visible.
374
+ yield* Effect.addFinalizer(exit => Effect.gen(function* () {
375
+ if (!Exit.hasInterrupts(exit)) {
376
+ return;
377
+ }
378
+ const maybeCurrentVNode = yield* Ref.get(maybeCurrentVNodeRef);
379
+ yield* Option.match(maybeCurrentVNode, {
380
+ onNone: () => Effect.void,
381
+ onSome: currentVNode => Effect.sync(() => {
382
+ const placeholderNode = patchVNode(Option.some(currentVNode), null, container).elm;
383
+ if (placeholderNode && placeholderNode.parentNode) {
384
+ placeholderNode.parentNode.replaceChild(container, placeholderNode);
385
+ container.replaceChildren();
386
+ }
387
+ }),
388
+ });
389
+ }));
390
+ const isCrashedRef = yield* Ref.make(false);
391
+ // NOTE: shared by every fiber's crash path: init render, render
392
+ // loop, message drain, and the Command and Subscription forks (a
393
+ // Command's Effect and a Subscription's Stream are typed with a
394
+ // `never` error channel, so a cause escaping one can only be a
395
+ // `resources` Layer build failure or an escaped defect, both
396
+ // unrecoverable). Each fiber catches its own cause so
397
+ // a failure surfaces as the crash view instead of dying silently
398
+ // and leaving the DOM frozen at the last successful render. The
399
+ // first crash wins: concurrent Command fibers can fail on the same
400
+ // broken Layer, and only one should report and render.
215
401
  const crashWith = (cause, maybeMessage) => Effect.gen(function* () {
402
+ const wasCrashed = yield* Ref.getAndSet(isCrashedRef, true);
403
+ if (wasCrashed) {
404
+ return;
405
+ }
216
406
  const model = yield* Ref.get(modelRef);
217
407
  const squashed = Cause.squash(cause);
218
408
  const error = squashed instanceof Error ? squashed : new Error(String(squashed));
219
- renderCrashView({ error, model, message: maybeMessage }, crash, container, maybeCurrentVNodeRef);
409
+ renderCrashView({ error, model, message: maybeMessage }, crash, container, maybeCurrentVNodeRef, manageDocument);
220
410
  });
411
+ yield* Effect.forEach(
412
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
413
+ initCommands, command => Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
414
+ attributes: command.args ?? {},
415
+ }), provideAllResources, Effect.flatMap(enqueueNormal), Effect.catchCause(cause => crashWith(cause, Option.none())))));
221
416
  // NOTE: queue-drain-fiber-local state. Kept as plain closure
222
417
  // variables instead of `Ref`s because nothing else reads or writes
223
418
  // them concurrently, and JS's single-threaded model already orders
@@ -276,9 +471,9 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
276
471
  if (!Array.isReadonlyArrayEmpty(commands)) {
277
472
  yield* Effect.forEach(
278
473
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
279
- commands, command => Effect.forkDetach(command.effect.pipe(Effect.withSpan(command.name, {
474
+ commands, command => Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
280
475
  attributes: command.args ?? {},
281
- }), provideAllResources, Effect.flatMap(enqueueNormal))));
476
+ }), provideAllResources, Effect.flatMap(enqueueNormal), Effect.catchCause(cause => crashWith(cause, Option.some(message))))));
282
477
  }
283
478
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
284
479
  const messageTag = message._tag;
@@ -336,7 +531,9 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
336
531
  const maybeCurrentVNode = yield* Ref.get(maybeCurrentVNodeRef);
337
532
  const patchedVNode = yield* Effect.sync(() => patchVNode(maybeCurrentVNode, nextVNode, container));
338
533
  yield* Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode));
339
- yield* Effect.sync(() => applyDocumentMetadata(nextDocument, patchedVNode.elm));
534
+ if (manageDocument) {
535
+ yield* Effect.sync(() => applyDocumentMetadata(nextDocument, patchedVNode.elm));
536
+ }
340
537
  }).pipe(Effect.provideService(Dispatch, dispatchService), Effect.provideService(MountTracker, mountTracker));
341
538
  const isInIframe = window.self !== window.top;
342
539
  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 => ({
@@ -405,9 +602,22 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
405
602
  maybeMessageSchema);
406
603
  }
407
604
  }
605
+ // NOTE: a fast-failing init Command (a `resources` Layer that
606
+ // throws synchronously) can render the crash view before this
607
+ // point. Rendering the init view would paint over it, so a crashed
608
+ // runtime suspends here instead, exactly like the failing-init-
609
+ // render path below.
610
+ const isCrashedBeforeInitRender = yield* Ref.get(isCrashedRef);
611
+ if (isCrashedBeforeInitRender) {
612
+ return yield* Effect.never;
613
+ }
408
614
  const initRenderExit = yield* Effect.exit(render(initModel, Option.none()));
409
615
  if (Exit.isFailure(initRenderExit)) {
410
- return yield* crashWith(initRenderExit.cause, Option.none());
616
+ yield* crashWith(initRenderExit.cause, Option.none());
617
+ // NOTE: suspend instead of returning. Completing would close the
618
+ // runtime scope and tear down the crash view; the scope must stay
619
+ // open until the runtime is interrupted (dispose, or page unload).
620
+ return yield* Effect.never;
411
621
  }
412
622
  const initMountEvents = drainMountEvents();
413
623
  yield* Option.match(maybeDevToolsStore, {
@@ -425,6 +635,14 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
425
635
  awaitNextFrame,
426
636
  isPaused: isPausedEffect,
427
637
  render: Effect.gen(function* () {
638
+ // NOTE: a Message that dirtied the model can also be the one
639
+ // whose Command crashed the runtime. Without this guard the
640
+ // next animation frame would render the live view over the
641
+ // crash view.
642
+ const isCrashed = yield* Ref.get(isCrashedRef);
643
+ if (isCrashed) {
644
+ return;
645
+ }
428
646
  const model = yield* Ref.get(modelRef);
429
647
  const maybeMessage = yield* Ref.get(maybeLastDirtyMessageRef);
430
648
  yield* render(model, maybeMessage);
@@ -435,18 +653,23 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
435
653
  });
436
654
  }),
437
655
  });
438
- yield* Effect.forkDetach(renderLoop.pipe(Effect.catchCause(cause => Effect.gen(function* () {
656
+ yield* Effect.forkIn(runtimeScope)(renderLoop.pipe(Effect.catchCause(cause => Effect.gen(function* () {
439
657
  const maybeMessage = yield* Ref.get(maybeLastDirtyMessageRef);
440
658
  yield* crashWith(cause, maybeMessage);
441
659
  }))));
442
- addBfcacheRestoreListener();
660
+ // NOTE: reloading on bfcache restore is a page-level decision, so
661
+ // only a page-owning runtime installs the listener. An embedded app
662
+ // must never force the host page to reload.
663
+ if (manageDocument) {
664
+ yield* Effect.acquireRelease(Effect.sync(() => addBfcacheRestoreListener()), removeBfcacheRestoreListener => Effect.sync(() => removeBfcacheRestoreListener()));
665
+ }
443
666
  if (subscriptions) {
444
667
  yield* pipe(subscriptions, Record.toEntries, Effect.forEach(([_key, { dependenciesSchema, modelToDependencies, keepAliveEquivalence, dependenciesToStream, },]) => Effect.gen(function* () {
445
668
  const latestDependenciesRef = yield* Ref.make(modelToDependencies(initModel));
446
669
  const equivalence = keepAliveEquivalence ??
447
670
  Schema.toEquivalence(dependenciesSchema);
448
671
  const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
449
- yield* Effect.forkDetach(modelStream.pipe(
672
+ yield* Effect.forkIn(runtimeScope)(modelStream.pipe(
450
673
  // NOTE: Ref.set runs upstream of Stream.changesWith on
451
674
  // every model change, so readDependencies() returns
452
675
  // current values even when the equivalence filter
@@ -460,7 +683,7 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
460
683
  return dependencies;
461
684
  })), Stream.changesWith(equivalence), Stream.switchMap(dependencies => dependenciesToStream(dependencies, () => Ref.getUnsafe(latestDependenciesRef))), Stream.runForEach(message =>
462
685
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
463
- enqueueHigh(message)), provideAllResources));
686
+ enqueueHigh(message)), provideAllResources, Effect.catchCause(cause => crashWith(cause, Option.none()))));
464
687
  }), {
465
688
  concurrency: 'unbounded',
466
689
  discard: true,
@@ -489,7 +712,7 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
489
712
  const forkManagedResourceLifecycle = ({ config, ref: resourceRef, }) => Effect.gen(function* () {
490
713
  const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
491
714
  const equivalence = Schema.toEquivalence(config.schema);
492
- yield* Effect.forkDetach(modelStream.pipe(Stream.map(config.modelToMaybeRequirements), Stream.changesWith(equivalence), Stream.switchMap(maybeRequirementsToLifecycle(config, resourceRef)), Stream.runForEach(Effect.flatMap(enqueueHigh))));
715
+ yield* Effect.forkIn(runtimeScope)(modelStream.pipe(Stream.map(config.modelToMaybeRequirements), Stream.changesWith(equivalence), Stream.switchMap(maybeRequirementsToLifecycle(config, resourceRef)), Stream.runForEach(Effect.flatMap(enqueueHigh))));
493
716
  });
494
717
  yield* Effect.forEach(managedResourceRefs, forkManagedResourceLifecycle, {
495
718
  concurrency: 'unbounded',
@@ -551,8 +774,20 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
551
774
  yield* processBatch(Array.prepend(rest, first));
552
775
  yield* drainQueue;
553
776
  })), Effect.catchCause(cause => crashWith(cause, currentMessage)));
777
+ // NOTE: reached only after the drain loop crashed and the crash view
778
+ // rendered. Suspending keeps the runtime scope open so the crash view
779
+ // and the DevTools overlay stay up for inspection; interruption
780
+ // (dispose, or page unload) still tears everything down.
781
+ yield* Effect.never;
554
782
  }));
555
- return { runtimeId, start };
783
+ const start = (hmrModel) => startWith(Option.none(), hmrModel);
784
+ const program = { runtimeId, start, ports };
785
+ runtimeInternals.set(program, {
786
+ startWith,
787
+ isEmbedActive: false,
788
+ maybeActiveFiber: Option.none(),
789
+ });
790
+ return program;
556
791
  };
557
792
  // NOTE: exported for `patchVNode.test.ts` to assert the dedupeSharedVNodes
558
793
  // wiring; not part of the public surface (`runtime/public.ts` is curated).
@@ -594,7 +829,7 @@ const applyDocumentMetadata = (nextDocument, mountedRoot) => {
594
829
  content: ogUrl,
595
830
  });
596
831
  };
597
- const renderCrashView = (context, crash, container, maybeCurrentVNodeRef) => {
832
+ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef, manageDocument) => {
598
833
  console.error('[foldkit] Application crash:', context.error);
599
834
  if (crash?.report) {
600
835
  try {
@@ -621,7 +856,10 @@ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef) => {
621
856
  }
622
857
  const maybeCurrentVNode = Effect.runSync(Ref.get(maybeCurrentVNodeRef));
623
858
  const patchedVNode = patchVNode(maybeCurrentVNode, crashDocument.body, container);
624
- applyDocumentMetadata(crashDocument, patchedVNode.elm);
859
+ Effect.runSync(Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode)));
860
+ if (manageDocument) {
861
+ applyDocumentMetadata(crashDocument, patchedVNode.elm);
862
+ }
625
863
  }
626
864
  catch (viewError) {
627
865
  console.error('[foldkit] crash.view failed:', viewError);
@@ -636,14 +874,17 @@ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef) => {
636
874
  }
637
875
  const maybeCurrentVNode = Effect.runSync(Ref.get(maybeCurrentVNodeRef));
638
876
  const patchedVNode = patchVNode(maybeCurrentVNode, fallbackDocument.body, container);
639
- applyDocumentMetadata(fallbackDocument, patchedVNode.elm);
877
+ Effect.runSync(Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode)));
878
+ if (manageDocument) {
879
+ applyDocumentMetadata(fallbackDocument, patchedVNode.elm);
880
+ }
640
881
  }
641
882
  };
642
- export function makeProgram(config) {
883
+ export function makeApplication(config) {
643
884
  const { container } = config;
644
885
  if (container === null) {
645
886
  throw new Error('[foldkit] Container is null. Make sure the element exists in the DOM ' +
646
- 'before calling makeProgram (e.g. that your <div id="root"></div> has ' +
887
+ 'before calling makeApplication (e.g. that your <div id="root"></div> has ' +
647
888
  'rendered, and your script runs after it).');
648
889
  }
649
890
  const hasRouting = 'routing' in config;
@@ -655,6 +896,8 @@ export function makeProgram(config) {
655
896
  Model: config.Model,
656
897
  update: config.update,
657
898
  view: config.view,
899
+ manageDocument: true,
900
+ ports: config.ports,
658
901
  ...(config.subscriptions && { subscriptions: config.subscriptions }),
659
902
  container,
660
903
  ...(hasRouting && { routing: config.routing }),
@@ -708,6 +951,79 @@ export function makeProgram(config) {
708
951
  }
709
952
  /* eslint-enable @typescript-eslint/consistent-type-assertions */
710
953
  }
954
+ const toCrashConfig = (nullableCrash) => {
955
+ if (Predicate.isUndefined(nullableCrash)) {
956
+ return undefined;
957
+ }
958
+ const elementCrashView = nullableCrash.view;
959
+ return {
960
+ ...(Predicate.isNotUndefined(elementCrashView) && {
961
+ view: (context) => ({
962
+ title: '',
963
+ body: elementCrashView(context),
964
+ }),
965
+ }),
966
+ ...(Predicate.isNotUndefined(nullableCrash.report) && {
967
+ report: nullableCrash.report,
968
+ }),
969
+ };
970
+ };
971
+ export function makeElement(config) {
972
+ const { container } = config;
973
+ if (container === null) {
974
+ throw new Error('[foldkit] Container is null. Make sure the element exists in the DOM ' +
975
+ 'before calling makeElement (e.g. that your <div id="root"></div> has ' +
976
+ 'rendered, and your script runs after it).');
977
+ }
978
+ const hasFlags = 'Flags' in config;
979
+ const elementView = config.view;
980
+ const view = (model) => ({
981
+ title: '',
982
+ body: elementView(model),
983
+ });
984
+ const nullableCrash = toCrashConfig(config.crash);
985
+ const baseConfig = {
986
+ Model: config.Model,
987
+ update: config.update,
988
+ view,
989
+ manageDocument: false,
990
+ ports: config.ports,
991
+ ...(config.subscriptions && { subscriptions: config.subscriptions }),
992
+ container,
993
+ ...(Predicate.isNotUndefined(nullableCrash) && { crash: nullableCrash }),
994
+ ...(Predicate.isNotUndefined(config.slowView) && {
995
+ slowView: config.slowView,
996
+ }),
997
+ ...(Predicate.isNotUndefined(config.freezeModel) && {
998
+ freezeModel: config.freezeModel,
999
+ }),
1000
+ ...(config.resources && { resources: config.resources }),
1001
+ ...(config.managedResources && {
1002
+ managedResources: config.managedResources,
1003
+ }),
1004
+ ...(Predicate.isNotUndefined(config.devTools) && {
1005
+ devTools: config.devTools,
1006
+ }),
1007
+ };
1008
+ /* eslint-disable @typescript-eslint/consistent-type-assertions */
1009
+ if (hasFlags) {
1010
+ return makeRuntime({
1011
+ ...baseConfig,
1012
+ Flags: config.Flags,
1013
+ flags: config.flags,
1014
+ init: (flags) => config.init(flags),
1015
+ });
1016
+ }
1017
+ else {
1018
+ return makeRuntime({
1019
+ ...baseConfig,
1020
+ Flags: Schema.Void,
1021
+ flags: Effect.succeed(undefined),
1022
+ init: () => config.init(),
1023
+ });
1024
+ }
1025
+ /* eslint-enable @typescript-eslint/consistent-type-assertions */
1026
+ }
711
1027
  const encodePreserveModelMessage = Schema.encodeUnknownSync(PreserveModelMessage);
712
1028
  const encodeRequestModelMessage = Schema.encodeUnknownSync(RequestModelMessage);
713
1029
  const decodeRestoreModelMessage = Schema.decodeUnknownExit(RestoreModelMessage);
@@ -735,36 +1051,120 @@ const microtaskSetImmediate = (callback) => {
735
1051
  };
736
1052
  const browserScheduler = new Scheduler.MixedScheduler('async', microtaskSetImmediate);
737
1053
  const provideBrowserScheduler = (effect) => Effect.provide(effect, Layer.succeed(Scheduler.Scheduler, browserScheduler));
738
- /** Starts a Foldkit runtime, with HMR support for development. */
1054
+ // NOTE: asks @foldkit/vite-plugin for a model preserved across the last HMR
1055
+ // reload. The plugin only serves a model whose preservation was flushed by a
1056
+ // reload, so a host-driven dispose-then-embed remount initializes fresh while
1057
+ // a code reload restores state.
1058
+ const resolveHmrModel = (runtimeId) => {
1059
+ const hot = import.meta.hot;
1060
+ if (!hot) {
1061
+ return Effect.succeed(undefined);
1062
+ }
1063
+ return pipe(Effect.callback(resume => {
1064
+ const handler = (message) => {
1065
+ Exit.match(decodeRestoreModelMessage(message), {
1066
+ onFailure: Function.constVoid,
1067
+ onSuccess: ({ id, model }) => {
1068
+ if (id === runtimeId) {
1069
+ hot.off('foldkit:restore-model', handler);
1070
+ resume(Effect.succeed(model));
1071
+ }
1072
+ },
1073
+ });
1074
+ };
1075
+ hot.on('foldkit:restore-model', handler);
1076
+ hot.send('foldkit:request-model', encodeRequestModelMessage(RequestModelMessage.make({ id: runtimeId })));
1077
+ return Effect.sync(() => hot.off('foldkit:restore-model', handler));
1078
+ }), Effect.timeout(PLUGIN_RESPONSE_TIMEOUT_MS), Effect.catchTag('TimeoutError', () => {
1079
+ console.warn('[foldkit] No response from @foldkit/vite-plugin. Add it to your vite.config.ts for HMR model preservation:\n\n' +
1080
+ " import { foldkit } from '@foldkit/vite-plugin'\n\n" +
1081
+ ' export default defineConfig({ plugins: [foldkit()] })\n\n' +
1082
+ 'Starting without HMR support.');
1083
+ return Effect.succeed(undefined);
1084
+ }));
1085
+ };
1086
+ /** Starts a Foldkit runtime that owns the page for the page's whole lifetime,
1087
+ * with HMR support for development. To start a runtime under a
1088
+ * host-controlled lifecycle instead, use `embed`. */
739
1089
  export const run = (program) => {
740
- if (import.meta.hot) {
741
- const hot = import.meta.hot;
742
- const { runtimeId, start } = program;
743
- const requestPreservedModel = pipe(Effect.callback(resume => {
744
- const handler = (message) => {
745
- Exit.match(decodeRestoreModelMessage(message), {
746
- onFailure: Function.constVoid,
747
- onSuccess: ({ id, model }) => {
748
- if (id === runtimeId) {
749
- hot.off('foldkit:restore-model', handler);
750
- resume(Effect.succeed(model));
751
- }
752
- },
753
- });
1090
+ BrowserRuntime.runMain(provideBrowserScheduler(Effect.flatMap(resolveHmrModel(program.runtimeId), program.start)));
1091
+ };
1092
+ const buildPortHandles = (ports, connector) => {
1093
+ const handles = {};
1094
+ if (Predicate.isNotUndefined(ports)) {
1095
+ Object.entries(ports.inbound ?? {}).forEach(([portName, port]) => {
1096
+ handles[portName] = {
1097
+ send: (value) => connector.sendInbound(portName, port, value),
754
1098
  };
755
- hot.on('foldkit:restore-model', handler);
756
- hot.send('foldkit:request-model', encodeRequestModelMessage(RequestModelMessage.make({ id: runtimeId })));
757
- return Effect.sync(() => hot.off('foldkit:restore-model', handler));
758
- }), Effect.timeout(PLUGIN_RESPONSE_TIMEOUT_MS), Effect.catchTag('TimeoutError', () => {
759
- console.warn('[foldkit] No response from @foldkit/vite-plugin. Add it to your vite.config.ts for HMR model preservation:\n\n' +
760
- " import { foldkit } from '@foldkit/vite-plugin'\n\n" +
761
- ' export default defineConfig({ plugins: [foldkit()] })\n\n' +
762
- 'Starting without HMR support.');
763
- return Effect.succeed(undefined);
764
- }), Effect.flatMap(start));
765
- BrowserRuntime.runMain(provideBrowserScheduler(requestPreservedModel));
1099
+ });
1100
+ Object.entries(ports.outbound ?? {}).forEach(([portName, port]) => {
1101
+ handles[portName] = {
1102
+ subscribe: (listener) => connector.addListener(port, listener),
1103
+ };
1104
+ });
766
1105
  }
767
- else {
768
- BrowserRuntime.runMain(provideBrowserScheduler(program.start()));
1106
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1107
+ return handles;
1108
+ };
1109
+ /**
1110
+ * Starts a Foldkit runtime under a host-controlled lifecycle and returns an
1111
+ * `EmbedHandle`. This is the entry point for embedding a Foldkit app inside
1112
+ * another application: the host pushes values in through the handle's inbound
1113
+ * Ports, listens to outbound Ports, and calls `dispose` when it unmounts the
1114
+ * app. The host never touches the Model or dispatches Messages directly; the
1115
+ * Schema-typed Ports are the whole boundary.
1116
+ *
1117
+ * Works with programs from both `makeApplication` and `makeElement`; for a
1118
+ * widget on a page the host owns, `makeElement` is the natural fit.
1119
+ *
1120
+ * A program can be embedded once at a time (it owns one container). After
1121
+ * `dispose`, the same container can be embedded again with a fresh program.
1122
+ *
1123
+ * ```ts
1124
+ * const handle = Runtime.embed(element)
1125
+ *
1126
+ * handle.ports.stepChanged.send(5)
1127
+ * const unsubscribe = handle.ports.countChanged.subscribe(count => {
1128
+ * console.log(count)
1129
+ * })
1130
+ *
1131
+ * handle.dispose()
1132
+ * ```
1133
+ */
1134
+ export const embed = (program) => {
1135
+ const nullableInternals = runtimeInternals.get(program);
1136
+ if (Predicate.isUndefined(nullableInternals)) {
1137
+ throw new Error('[foldkit] embed expects a program created by makeApplication or makeElement.');
1138
+ }
1139
+ const internals = nullableInternals;
1140
+ if (internals.isEmbedActive) {
1141
+ throw new Error('[foldkit] This program is already embedded. Dispose the existing ' +
1142
+ 'handle first, or create a separate program: each program owns one ' +
1143
+ 'container.');
769
1144
  }
1145
+ internals.isEmbedActive = true;
1146
+ const connector = makeHostConnector();
1147
+ // NOTE: a dispose immediately followed by a fresh embed (React strict mode
1148
+ // runs effects exactly that way) must not start the new runtime while the
1149
+ // old one is still tearing down: the teardown finalizer is what puts the
1150
+ // container element back in the DOM. Awaiting the previous fiber's exit
1151
+ // sequences the two.
1152
+ const startEffect = pipe(Option.match(internals.maybeActiveFiber, {
1153
+ onNone: () => Effect.void,
1154
+ onSome: previousFiber => Effect.asVoid(Fiber.await(previousFiber)),
1155
+ }), Effect.andThen(resolveHmrModel(program.runtimeId)), Effect.flatMap(hmrModel => internals.startWith(Option.some(connector), hmrModel)));
1156
+ const fiber = Effect.runFork(provideBrowserScheduler(startEffect));
1157
+ internals.maybeActiveFiber = Option.some(fiber);
1158
+ let isHandleDisposed = false;
1159
+ const dispose = () => {
1160
+ if (isHandleDisposed) {
1161
+ return;
1162
+ }
1163
+ isHandleDisposed = true;
1164
+ connector.dispose();
1165
+ internals.isEmbedActive = false;
1166
+ Effect.runFork(Fiber.interrupt(fiber));
1167
+ };
1168
+ const ports = buildPortHandles(program.ports, connector);
1169
+ return { ports, dispose };
770
1170
  };