foldkit 0.106.0 → 0.107.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/README.md +5 -4
- package/dist/devTools/overlay.d.ts +1 -1
- package/dist/devTools/overlay.d.ts.map +1 -1
- package/dist/devTools/overlay.js +11 -12
- package/dist/devTools/webSocketBridge.d.ts +2 -2
- package/dist/devTools/webSocketBridge.d.ts.map +1 -1
- package/dist/devTools/webSocketBridge.js +9 -0
- package/dist/html/index.d.ts +5 -1
- package/dist/html/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/port/index.d.ts +2 -0
- package/dist/port/index.d.ts.map +1 -0
- package/dist/port/index.js +1 -0
- package/dist/port/port.d.ts +143 -0
- package/dist/port/port.d.ts.map +1 -0
- package/dist/port/port.js +156 -0
- package/dist/port/public.d.ts +3 -0
- package/dist/port/public.d.ts.map +1 -0
- package/dist/port/public.js +1 -0
- package/dist/runtime/browserListeners.d.ts +3 -3
- package/dist/runtime/browserListeners.d.ts.map +1 -1
- package/dist/runtime/browserListeners.js +23 -5
- package/dist/runtime/crashUI.d.ts.map +1 -1
- package/dist/runtime/crashUI.js +1 -3
- package/dist/runtime/public.d.ts +2 -2
- package/dist/runtime/public.d.ts.map +1 -1
- package/dist/runtime/public.js +1 -1
- package/dist/runtime/runtime.d.ts +172 -28
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +407 -49
- package/dist/runtime/subscription.d.ts +6 -1
- package/dist/runtime/subscription.d.ts.map +1 -1
- package/dist/ui/dragAndDrop/index.d.ts +212 -246
- package/dist/ui/dragAndDrop/index.d.ts.map +1 -1
- package/dist/ui/slider/index.d.ts +124 -179
- package/dist/ui/slider/index.d.ts.map +1 -1
- package/dist/ui/virtualList/index.d.ts +28 -38
- package/dist/ui/virtualList/index.d.ts.map +1 -1
- package/package.json +5 -1
package/dist/runtime/runtime.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
@@ -107,6 +240,14 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
|
|
|
107
240
|
});
|
|
108
241
|
});
|
|
109
242
|
const maybeResourceLayer = Option.fromNullishOr(resources);
|
|
243
|
+
const maybePortChannels = pipe(Option.fromNullishOr(ports), Option.map(portsConfig => makePortChannels(portsConfig, maybeConnector)));
|
|
244
|
+
yield* Option.match(Option.all({
|
|
245
|
+
connector: maybeConnector,
|
|
246
|
+
portChannels: maybePortChannels,
|
|
247
|
+
}), {
|
|
248
|
+
onNone: () => Effect.void,
|
|
249
|
+
onSome: ({ connector, portChannels }) => Effect.acquireRelease(Effect.sync(() => connector.bind(portChannels.deliverInbound)), () => Effect.sync(() => connector.unbind())),
|
|
250
|
+
});
|
|
110
251
|
// NOTE: One boundary registry per runtime instance, shared
|
|
111
252
|
// across renders so Submodel wrap descriptors registered by
|
|
112
253
|
// h.submodel persist between renders. The render function calls
|
|
@@ -134,13 +275,17 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
|
|
|
134
275
|
onNone: () => effect,
|
|
135
276
|
onSome: resourceLayer => Effect.provide(effect, resourceLayer),
|
|
136
277
|
});
|
|
137
|
-
|
|
278
|
+
const withManagedResources = Option.match(maybeManagedResourceLayer, {
|
|
138
279
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
139
280
|
onNone: () => withResources,
|
|
140
281
|
onSome: managedLayer =>
|
|
141
282
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
142
283
|
Effect.provide(withResources, managedLayer),
|
|
143
284
|
});
|
|
285
|
+
return Option.match(maybePortChannels, {
|
|
286
|
+
onNone: () => withManagedResources,
|
|
287
|
+
onSome: portChannels => Effect.provideService(withManagedResources, __CurrentPortChannels, portChannels.channels),
|
|
288
|
+
});
|
|
144
289
|
};
|
|
145
290
|
const flags = yield* resolveFlags;
|
|
146
291
|
const ModelJsonCodec = Schema.toCodecJson(
|
|
@@ -200,14 +345,39 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
|
|
|
200
345
|
const modelPubSub = yield* PubSub.unbounded();
|
|
201
346
|
yield* Effect.forEach(
|
|
202
347
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
203
|
-
initCommands, command => Effect.
|
|
348
|
+
initCommands, command => Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
|
|
204
349
|
attributes: command.args ?? {},
|
|
205
350
|
}), provideAllResources, Effect.flatMap(enqueueNormal))));
|
|
206
351
|
if (routingConfig) {
|
|
207
|
-
addNavigationEventListeners(enqueueHighUnsafe, routingConfig);
|
|
352
|
+
yield* Effect.acquireRelease(Effect.sync(() => addNavigationEventListeners(enqueueHighUnsafe, routingConfig)), removeNavigationEventListeners => Effect.sync(() => removeNavigationEventListeners()));
|
|
208
353
|
}
|
|
209
354
|
const modelRef = yield* Ref.make(initModel);
|
|
210
355
|
const maybeCurrentVNodeRef = yield* Ref.make(Option.none());
|
|
356
|
+
// NOTE: registered before any perpetual fiber is forked so it runs
|
|
357
|
+
// after they are interrupted (scope finalizers are LIFO). Patching to
|
|
358
|
+
// an empty tree fires snabbdom destroy hooks, which is what releases
|
|
359
|
+
// Mounts; swapping the placeholder for the original container leaves
|
|
360
|
+
// the host DOM as it was before the first render, ready for a fresh
|
|
361
|
+
// embed of the same container. Gated on interruption: that is the
|
|
362
|
+
// dispose path. A runtime that stops because it crashed completes
|
|
363
|
+
// normally after rendering the crash view, and the crash view must
|
|
364
|
+
// stay visible.
|
|
365
|
+
yield* Effect.addFinalizer(exit => Effect.gen(function* () {
|
|
366
|
+
if (!Exit.hasInterrupts(exit)) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const maybeCurrentVNode = yield* Ref.get(maybeCurrentVNodeRef);
|
|
370
|
+
yield* Option.match(maybeCurrentVNode, {
|
|
371
|
+
onNone: () => Effect.void,
|
|
372
|
+
onSome: currentVNode => Effect.sync(() => {
|
|
373
|
+
const placeholderNode = patchVNode(Option.some(currentVNode), null, container).elm;
|
|
374
|
+
if (placeholderNode && placeholderNode.parentNode) {
|
|
375
|
+
placeholderNode.parentNode.replaceChild(container, placeholderNode);
|
|
376
|
+
container.replaceChildren();
|
|
377
|
+
}
|
|
378
|
+
}),
|
|
379
|
+
});
|
|
380
|
+
}));
|
|
211
381
|
// NOTE: shared by every perpetual fiber's crash path (init render,
|
|
212
382
|
// render loop, message drain). Each fiber catches its own cause so a
|
|
213
383
|
// failure surfaces as the crash view instead of dying silently and
|
|
@@ -216,7 +386,7 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
|
|
|
216
386
|
const model = yield* Ref.get(modelRef);
|
|
217
387
|
const squashed = Cause.squash(cause);
|
|
218
388
|
const error = squashed instanceof Error ? squashed : new Error(String(squashed));
|
|
219
|
-
renderCrashView({ error, model, message: maybeMessage }, crash, container, maybeCurrentVNodeRef);
|
|
389
|
+
renderCrashView({ error, model, message: maybeMessage }, crash, container, maybeCurrentVNodeRef, manageDocument);
|
|
220
390
|
});
|
|
221
391
|
// NOTE: queue-drain-fiber-local state. Kept as plain closure
|
|
222
392
|
// variables instead of `Ref`s because nothing else reads or writes
|
|
@@ -276,7 +446,7 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
|
|
|
276
446
|
if (!Array.isReadonlyArrayEmpty(commands)) {
|
|
277
447
|
yield* Effect.forEach(
|
|
278
448
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
279
|
-
commands, command => Effect.
|
|
449
|
+
commands, command => Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
|
|
280
450
|
attributes: command.args ?? {},
|
|
281
451
|
}), provideAllResources, Effect.flatMap(enqueueNormal))));
|
|
282
452
|
}
|
|
@@ -336,7 +506,9 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
|
|
|
336
506
|
const maybeCurrentVNode = yield* Ref.get(maybeCurrentVNodeRef);
|
|
337
507
|
const patchedVNode = yield* Effect.sync(() => patchVNode(maybeCurrentVNode, nextVNode, container));
|
|
338
508
|
yield* Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode));
|
|
339
|
-
|
|
509
|
+
if (manageDocument) {
|
|
510
|
+
yield* Effect.sync(() => applyDocumentMetadata(nextDocument, patchedVNode.elm));
|
|
511
|
+
}
|
|
340
512
|
}).pipe(Effect.provideService(Dispatch, dispatchService), Effect.provideService(MountTracker, mountTracker));
|
|
341
513
|
const isInIframe = window.self !== window.top;
|
|
342
514
|
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 => ({
|
|
@@ -407,7 +579,11 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
|
|
|
407
579
|
}
|
|
408
580
|
const initRenderExit = yield* Effect.exit(render(initModel, Option.none()));
|
|
409
581
|
if (Exit.isFailure(initRenderExit)) {
|
|
410
|
-
|
|
582
|
+
yield* crashWith(initRenderExit.cause, Option.none());
|
|
583
|
+
// NOTE: suspend instead of returning. Completing would close the
|
|
584
|
+
// runtime scope and tear down the crash view; the scope must stay
|
|
585
|
+
// open until the runtime is interrupted (dispose, or page unload).
|
|
586
|
+
return yield* Effect.never;
|
|
411
587
|
}
|
|
412
588
|
const initMountEvents = drainMountEvents();
|
|
413
589
|
yield* Option.match(maybeDevToolsStore, {
|
|
@@ -435,18 +611,23 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
|
|
|
435
611
|
});
|
|
436
612
|
}),
|
|
437
613
|
});
|
|
438
|
-
yield* Effect.
|
|
614
|
+
yield* Effect.forkIn(runtimeScope)(renderLoop.pipe(Effect.catchCause(cause => Effect.gen(function* () {
|
|
439
615
|
const maybeMessage = yield* Ref.get(maybeLastDirtyMessageRef);
|
|
440
616
|
yield* crashWith(cause, maybeMessage);
|
|
441
617
|
}))));
|
|
442
|
-
|
|
618
|
+
// NOTE: reloading on bfcache restore is a page-level decision, so
|
|
619
|
+
// only a page-owning runtime installs the listener. An embedded app
|
|
620
|
+
// must never force the host page to reload.
|
|
621
|
+
if (manageDocument) {
|
|
622
|
+
yield* Effect.acquireRelease(Effect.sync(() => addBfcacheRestoreListener()), removeBfcacheRestoreListener => Effect.sync(() => removeBfcacheRestoreListener()));
|
|
623
|
+
}
|
|
443
624
|
if (subscriptions) {
|
|
444
625
|
yield* pipe(subscriptions, Record.toEntries, Effect.forEach(([_key, { dependenciesSchema, modelToDependencies, keepAliveEquivalence, dependenciesToStream, },]) => Effect.gen(function* () {
|
|
445
626
|
const latestDependenciesRef = yield* Ref.make(modelToDependencies(initModel));
|
|
446
627
|
const equivalence = keepAliveEquivalence ??
|
|
447
628
|
Schema.toEquivalence(dependenciesSchema);
|
|
448
629
|
const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
|
|
449
|
-
yield* Effect.
|
|
630
|
+
yield* Effect.forkIn(runtimeScope)(modelStream.pipe(
|
|
450
631
|
// NOTE: Ref.set runs upstream of Stream.changesWith on
|
|
451
632
|
// every model change, so readDependencies() returns
|
|
452
633
|
// current values even when the equivalence filter
|
|
@@ -489,7 +670,7 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
|
|
|
489
670
|
const forkManagedResourceLifecycle = ({ config, ref: resourceRef, }) => Effect.gen(function* () {
|
|
490
671
|
const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
|
|
491
672
|
const equivalence = Schema.toEquivalence(config.schema);
|
|
492
|
-
yield* Effect.
|
|
673
|
+
yield* Effect.forkIn(runtimeScope)(modelStream.pipe(Stream.map(config.modelToMaybeRequirements), Stream.changesWith(equivalence), Stream.switchMap(maybeRequirementsToLifecycle(config, resourceRef)), Stream.runForEach(Effect.flatMap(enqueueHigh))));
|
|
493
674
|
});
|
|
494
675
|
yield* Effect.forEach(managedResourceRefs, forkManagedResourceLifecycle, {
|
|
495
676
|
concurrency: 'unbounded',
|
|
@@ -551,8 +732,20 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
|
|
|
551
732
|
yield* processBatch(Array.prepend(rest, first));
|
|
552
733
|
yield* drainQueue;
|
|
553
734
|
})), Effect.catchCause(cause => crashWith(cause, currentMessage)));
|
|
735
|
+
// NOTE: reached only after the drain loop crashed and the crash view
|
|
736
|
+
// rendered. Suspending keeps the runtime scope open so the crash view
|
|
737
|
+
// and the DevTools overlay stay up for inspection; interruption
|
|
738
|
+
// (dispose, or page unload) still tears everything down.
|
|
739
|
+
yield* Effect.never;
|
|
554
740
|
}));
|
|
555
|
-
|
|
741
|
+
const start = (hmrModel) => startWith(Option.none(), hmrModel);
|
|
742
|
+
const program = { runtimeId, start, ports };
|
|
743
|
+
runtimeInternals.set(program, {
|
|
744
|
+
startWith,
|
|
745
|
+
isEmbedActive: false,
|
|
746
|
+
maybeActiveFiber: Option.none(),
|
|
747
|
+
});
|
|
748
|
+
return program;
|
|
556
749
|
};
|
|
557
750
|
// NOTE: exported for `patchVNode.test.ts` to assert the dedupeSharedVNodes
|
|
558
751
|
// wiring; not part of the public surface (`runtime/public.ts` is curated).
|
|
@@ -594,7 +787,7 @@ const applyDocumentMetadata = (nextDocument, mountedRoot) => {
|
|
|
594
787
|
content: ogUrl,
|
|
595
788
|
});
|
|
596
789
|
};
|
|
597
|
-
const renderCrashView = (context, crash, container, maybeCurrentVNodeRef) => {
|
|
790
|
+
const renderCrashView = (context, crash, container, maybeCurrentVNodeRef, manageDocument) => {
|
|
598
791
|
console.error('[foldkit] Application crash:', context.error);
|
|
599
792
|
if (crash?.report) {
|
|
600
793
|
try {
|
|
@@ -621,7 +814,10 @@ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef) => {
|
|
|
621
814
|
}
|
|
622
815
|
const maybeCurrentVNode = Effect.runSync(Ref.get(maybeCurrentVNodeRef));
|
|
623
816
|
const patchedVNode = patchVNode(maybeCurrentVNode, crashDocument.body, container);
|
|
624
|
-
|
|
817
|
+
Effect.runSync(Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode)));
|
|
818
|
+
if (manageDocument) {
|
|
819
|
+
applyDocumentMetadata(crashDocument, patchedVNode.elm);
|
|
820
|
+
}
|
|
625
821
|
}
|
|
626
822
|
catch (viewError) {
|
|
627
823
|
console.error('[foldkit] crash.view failed:', viewError);
|
|
@@ -636,14 +832,17 @@ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef) => {
|
|
|
636
832
|
}
|
|
637
833
|
const maybeCurrentVNode = Effect.runSync(Ref.get(maybeCurrentVNodeRef));
|
|
638
834
|
const patchedVNode = patchVNode(maybeCurrentVNode, fallbackDocument.body, container);
|
|
639
|
-
|
|
835
|
+
Effect.runSync(Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode)));
|
|
836
|
+
if (manageDocument) {
|
|
837
|
+
applyDocumentMetadata(fallbackDocument, patchedVNode.elm);
|
|
838
|
+
}
|
|
640
839
|
}
|
|
641
840
|
};
|
|
642
|
-
export function
|
|
841
|
+
export function makeApplication(config) {
|
|
643
842
|
const { container } = config;
|
|
644
843
|
if (container === null) {
|
|
645
844
|
throw new Error('[foldkit] Container is null. Make sure the element exists in the DOM ' +
|
|
646
|
-
'before calling
|
|
845
|
+
'before calling makeApplication (e.g. that your <div id="root"></div> has ' +
|
|
647
846
|
'rendered, and your script runs after it).');
|
|
648
847
|
}
|
|
649
848
|
const hasRouting = 'routing' in config;
|
|
@@ -655,6 +854,8 @@ export function makeProgram(config) {
|
|
|
655
854
|
Model: config.Model,
|
|
656
855
|
update: config.update,
|
|
657
856
|
view: config.view,
|
|
857
|
+
manageDocument: true,
|
|
858
|
+
ports: config.ports,
|
|
658
859
|
...(config.subscriptions && { subscriptions: config.subscriptions }),
|
|
659
860
|
container,
|
|
660
861
|
...(hasRouting && { routing: config.routing }),
|
|
@@ -708,6 +909,79 @@ export function makeProgram(config) {
|
|
|
708
909
|
}
|
|
709
910
|
/* eslint-enable @typescript-eslint/consistent-type-assertions */
|
|
710
911
|
}
|
|
912
|
+
const toCrashConfig = (nullableCrash) => {
|
|
913
|
+
if (Predicate.isUndefined(nullableCrash)) {
|
|
914
|
+
return undefined;
|
|
915
|
+
}
|
|
916
|
+
const elementCrashView = nullableCrash.view;
|
|
917
|
+
return {
|
|
918
|
+
...(Predicate.isNotUndefined(elementCrashView) && {
|
|
919
|
+
view: (context) => ({
|
|
920
|
+
title: '',
|
|
921
|
+
body: elementCrashView(context),
|
|
922
|
+
}),
|
|
923
|
+
}),
|
|
924
|
+
...(Predicate.isNotUndefined(nullableCrash.report) && {
|
|
925
|
+
report: nullableCrash.report,
|
|
926
|
+
}),
|
|
927
|
+
};
|
|
928
|
+
};
|
|
929
|
+
export function makeElement(config) {
|
|
930
|
+
const { container } = config;
|
|
931
|
+
if (container === null) {
|
|
932
|
+
throw new Error('[foldkit] Container is null. Make sure the element exists in the DOM ' +
|
|
933
|
+
'before calling makeElement (e.g. that your <div id="root"></div> has ' +
|
|
934
|
+
'rendered, and your script runs after it).');
|
|
935
|
+
}
|
|
936
|
+
const hasFlags = 'Flags' in config;
|
|
937
|
+
const elementView = config.view;
|
|
938
|
+
const view = (model) => ({
|
|
939
|
+
title: '',
|
|
940
|
+
body: elementView(model),
|
|
941
|
+
});
|
|
942
|
+
const nullableCrash = toCrashConfig(config.crash);
|
|
943
|
+
const baseConfig = {
|
|
944
|
+
Model: config.Model,
|
|
945
|
+
update: config.update,
|
|
946
|
+
view,
|
|
947
|
+
manageDocument: false,
|
|
948
|
+
ports: config.ports,
|
|
949
|
+
...(config.subscriptions && { subscriptions: config.subscriptions }),
|
|
950
|
+
container,
|
|
951
|
+
...(Predicate.isNotUndefined(nullableCrash) && { crash: nullableCrash }),
|
|
952
|
+
...(Predicate.isNotUndefined(config.slowView) && {
|
|
953
|
+
slowView: config.slowView,
|
|
954
|
+
}),
|
|
955
|
+
...(Predicate.isNotUndefined(config.freezeModel) && {
|
|
956
|
+
freezeModel: config.freezeModel,
|
|
957
|
+
}),
|
|
958
|
+
...(config.resources && { resources: config.resources }),
|
|
959
|
+
...(config.managedResources && {
|
|
960
|
+
managedResources: config.managedResources,
|
|
961
|
+
}),
|
|
962
|
+
...(Predicate.isNotUndefined(config.devTools) && {
|
|
963
|
+
devTools: config.devTools,
|
|
964
|
+
}),
|
|
965
|
+
};
|
|
966
|
+
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
|
967
|
+
if (hasFlags) {
|
|
968
|
+
return makeRuntime({
|
|
969
|
+
...baseConfig,
|
|
970
|
+
Flags: config.Flags,
|
|
971
|
+
flags: config.flags,
|
|
972
|
+
init: (flags) => config.init(flags),
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
return makeRuntime({
|
|
977
|
+
...baseConfig,
|
|
978
|
+
Flags: Schema.Void,
|
|
979
|
+
flags: Effect.succeed(undefined),
|
|
980
|
+
init: () => config.init(),
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
/* eslint-enable @typescript-eslint/consistent-type-assertions */
|
|
984
|
+
}
|
|
711
985
|
const encodePreserveModelMessage = Schema.encodeUnknownSync(PreserveModelMessage);
|
|
712
986
|
const encodeRequestModelMessage = Schema.encodeUnknownSync(RequestModelMessage);
|
|
713
987
|
const decodeRestoreModelMessage = Schema.decodeUnknownExit(RestoreModelMessage);
|
|
@@ -735,36 +1009,120 @@ const microtaskSetImmediate = (callback) => {
|
|
|
735
1009
|
};
|
|
736
1010
|
const browserScheduler = new Scheduler.MixedScheduler('async', microtaskSetImmediate);
|
|
737
1011
|
const provideBrowserScheduler = (effect) => Effect.provide(effect, Layer.succeed(Scheduler.Scheduler, browserScheduler));
|
|
738
|
-
|
|
1012
|
+
// NOTE: asks @foldkit/vite-plugin for a model preserved across the last HMR
|
|
1013
|
+
// reload. The plugin only serves a model whose preservation was flushed by a
|
|
1014
|
+
// reload, so a host-driven dispose-then-embed remount initializes fresh while
|
|
1015
|
+
// a code reload restores state.
|
|
1016
|
+
const resolveHmrModel = (runtimeId) => {
|
|
1017
|
+
const hot = import.meta.hot;
|
|
1018
|
+
if (!hot) {
|
|
1019
|
+
return Effect.succeed(undefined);
|
|
1020
|
+
}
|
|
1021
|
+
return pipe(Effect.callback(resume => {
|
|
1022
|
+
const handler = (message) => {
|
|
1023
|
+
Exit.match(decodeRestoreModelMessage(message), {
|
|
1024
|
+
onFailure: Function.constVoid,
|
|
1025
|
+
onSuccess: ({ id, model }) => {
|
|
1026
|
+
if (id === runtimeId) {
|
|
1027
|
+
hot.off('foldkit:restore-model', handler);
|
|
1028
|
+
resume(Effect.succeed(model));
|
|
1029
|
+
}
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
};
|
|
1033
|
+
hot.on('foldkit:restore-model', handler);
|
|
1034
|
+
hot.send('foldkit:request-model', encodeRequestModelMessage(RequestModelMessage.make({ id: runtimeId })));
|
|
1035
|
+
return Effect.sync(() => hot.off('foldkit:restore-model', handler));
|
|
1036
|
+
}), Effect.timeout(PLUGIN_RESPONSE_TIMEOUT_MS), Effect.catchTag('TimeoutError', () => {
|
|
1037
|
+
console.warn('[foldkit] No response from @foldkit/vite-plugin. Add it to your vite.config.ts for HMR model preservation:\n\n' +
|
|
1038
|
+
" import { foldkit } from '@foldkit/vite-plugin'\n\n" +
|
|
1039
|
+
' export default defineConfig({ plugins: [foldkit()] })\n\n' +
|
|
1040
|
+
'Starting without HMR support.');
|
|
1041
|
+
return Effect.succeed(undefined);
|
|
1042
|
+
}));
|
|
1043
|
+
};
|
|
1044
|
+
/** Starts a Foldkit runtime that owns the page for the page's whole lifetime,
|
|
1045
|
+
* with HMR support for development. To start a runtime under a
|
|
1046
|
+
* host-controlled lifecycle instead, use `embed`. */
|
|
739
1047
|
export const run = (program) => {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
});
|
|
1048
|
+
BrowserRuntime.runMain(provideBrowserScheduler(Effect.flatMap(resolveHmrModel(program.runtimeId), program.start)));
|
|
1049
|
+
};
|
|
1050
|
+
const buildPortHandles = (ports, connector) => {
|
|
1051
|
+
const handles = {};
|
|
1052
|
+
if (Predicate.isNotUndefined(ports)) {
|
|
1053
|
+
Object.entries(ports.inbound ?? {}).forEach(([portName, port]) => {
|
|
1054
|
+
handles[portName] = {
|
|
1055
|
+
send: (value) => connector.sendInbound(portName, port, value),
|
|
1056
|
+
};
|
|
1057
|
+
});
|
|
1058
|
+
Object.entries(ports.outbound ?? {}).forEach(([portName, port]) => {
|
|
1059
|
+
handles[portName] = {
|
|
1060
|
+
subscribe: (listener) => connector.addListener(port, listener),
|
|
754
1061
|
};
|
|
755
|
-
|
|
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));
|
|
1062
|
+
});
|
|
766
1063
|
}
|
|
767
|
-
|
|
768
|
-
|
|
1064
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
1065
|
+
return handles;
|
|
1066
|
+
};
|
|
1067
|
+
/**
|
|
1068
|
+
* Starts a Foldkit runtime under a host-controlled lifecycle and returns an
|
|
1069
|
+
* `EmbedHandle`. This is the entry point for embedding a Foldkit app inside
|
|
1070
|
+
* another application: the host pushes values in through the handle's inbound
|
|
1071
|
+
* Ports, listens to outbound Ports, and calls `dispose` when it unmounts the
|
|
1072
|
+
* app. The host never touches the Model or dispatches Messages directly; the
|
|
1073
|
+
* Schema-typed Ports are the whole boundary.
|
|
1074
|
+
*
|
|
1075
|
+
* Works with programs from both `makeApplication` and `makeElement`; for a
|
|
1076
|
+
* widget on a page the host owns, `makeElement` is the natural fit.
|
|
1077
|
+
*
|
|
1078
|
+
* A program can be embedded once at a time (it owns one container). After
|
|
1079
|
+
* `dispose`, the same container can be embedded again with a fresh program.
|
|
1080
|
+
*
|
|
1081
|
+
* ```ts
|
|
1082
|
+
* const handle = Runtime.embed(element)
|
|
1083
|
+
*
|
|
1084
|
+
* handle.ports.stepChanged.send(5)
|
|
1085
|
+
* const unsubscribe = handle.ports.countChanged.subscribe(count => {
|
|
1086
|
+
* console.log(count)
|
|
1087
|
+
* })
|
|
1088
|
+
*
|
|
1089
|
+
* handle.dispose()
|
|
1090
|
+
* ```
|
|
1091
|
+
*/
|
|
1092
|
+
export const embed = (program) => {
|
|
1093
|
+
const nullableInternals = runtimeInternals.get(program);
|
|
1094
|
+
if (Predicate.isUndefined(nullableInternals)) {
|
|
1095
|
+
throw new Error('[foldkit] embed expects a program created by makeApplication or makeElement.');
|
|
769
1096
|
}
|
|
1097
|
+
const internals = nullableInternals;
|
|
1098
|
+
if (internals.isEmbedActive) {
|
|
1099
|
+
throw new Error('[foldkit] This program is already embedded. Dispose the existing ' +
|
|
1100
|
+
'handle first, or create a separate program: each program owns one ' +
|
|
1101
|
+
'container.');
|
|
1102
|
+
}
|
|
1103
|
+
internals.isEmbedActive = true;
|
|
1104
|
+
const connector = makeHostConnector();
|
|
1105
|
+
// NOTE: a dispose immediately followed by a fresh embed (React strict mode
|
|
1106
|
+
// runs effects exactly that way) must not start the new runtime while the
|
|
1107
|
+
// old one is still tearing down: the teardown finalizer is what puts the
|
|
1108
|
+
// container element back in the DOM. Awaiting the previous fiber's exit
|
|
1109
|
+
// sequences the two.
|
|
1110
|
+
const startEffect = pipe(Option.match(internals.maybeActiveFiber, {
|
|
1111
|
+
onNone: () => Effect.void,
|
|
1112
|
+
onSome: previousFiber => Effect.asVoid(Fiber.await(previousFiber)),
|
|
1113
|
+
}), Effect.andThen(resolveHmrModel(program.runtimeId)), Effect.flatMap(hmrModel => internals.startWith(Option.some(connector), hmrModel)));
|
|
1114
|
+
const fiber = Effect.runFork(provideBrowserScheduler(startEffect));
|
|
1115
|
+
internals.maybeActiveFiber = Option.some(fiber);
|
|
1116
|
+
let isHandleDisposed = false;
|
|
1117
|
+
const dispose = () => {
|
|
1118
|
+
if (isHandleDisposed) {
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
isHandleDisposed = true;
|
|
1122
|
+
connector.dispose();
|
|
1123
|
+
internals.isEmbedActive = false;
|
|
1124
|
+
Effect.runFork(Fiber.interrupt(fiber));
|
|
1125
|
+
};
|
|
1126
|
+
const ports = buildPortHandles(program.ports, connector);
|
|
1127
|
+
return { ports, dispose };
|
|
770
1128
|
};
|
|
@@ -5,7 +5,12 @@ type SubscriptionBrand = {
|
|
|
5
5
|
type DependenciesSchema<Dependencies> = Schema.Schema<Dependencies> & {
|
|
6
6
|
readonly fields: Schema.Struct.Fields;
|
|
7
7
|
};
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The entry shape produced by helpers like `Subscription.persistent` and
|
|
10
|
+
* `Port.subscription` before branding. Pass values of this shape into
|
|
11
|
+
* `Subscription.make` as entry values.
|
|
12
|
+
*/
|
|
13
|
+
export type EntryWithoutKeepAlive<Model, Message, Dependencies, Services> = {
|
|
9
14
|
readonly dependenciesSchema: DependenciesSchema<Dependencies>;
|
|
10
15
|
readonly modelToDependencies: (model: Model) => Dependencies;
|
|
11
16
|
readonly keepAliveEquivalence?: never;
|