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
@@ -1,587 +1,34 @@
1
- import { Array, Cause, Context, Duration, Effect, Exit, Fiber, Function, Layer, Match, Option, Predicate, PubSub, Record, Ref, Runtime, Scheduler, Schema, Stream, SubscriptionRef, pipe, } from 'effect';
2
- import { HYDRATION_BUILD_ATTRIBUTE } from '../buildToken.js';
3
- import { __CurrentRegistry as __CurrentInterruptRegistry, __makeRegistry as __makeInterruptRegistry, } from '../command/interruptible/index.js';
4
- import { createDevToolsStore, } from '../devTools/store.js';
5
- import { startWebSocketBridge } from '../devTools/webSocketBridge.js';
6
- import { __beginRender as beginHtmlRender, __beginReplayRender as beginReplayHtmlRender, __clearRuntime as clearHtmlRuntime, __createBoundaryRegistry as createHtmlBoundaryRegistry, __endReplayRender as endReplayHtmlRender, __flushReplayUnmountsAfterPatchFailure as flushReplayUnmountsAfterPatchFailure, __htmlBuilder as htmlBuilderFor, __setRuntime as setHtmlRuntime, textDirectionToAttribute, } from '../html/index.js';
7
- import { __hydrateVNode } from '../hydrate.js';
8
- import { FOLDKIT_APP_ATTRIBUTE, FOLDKIT_FLAGS_ATTRIBUTE, } from '../hydrationMarker.js';
9
- import { MountRuntime, MountTracker, liveViewStateChanges, } from '../mount/index.js';
10
- import { __CurrentPortChannels, __makeInboundChannel, } from '../port/index.js';
1
+ import { Array, Duration, Effect, Exit, Option, Predicate, PubSub, Schema, pipe, } from 'effect';
2
+ import { __htmlBuilder as htmlBuilderFor, } from '../html/index.js';
11
3
  import { RenderCommit, createCommitNotifier } from '../render/commit.js';
12
4
  import { fromString as urlFromString } from '../url/index.js';
13
- import { __patchVNode, __recoverVNodeAfterPatchFailure, } from '../vdom.js';
14
- import { addNavigationEventListeners } from './browserListeners.js';
15
- import { defaultCrashView, noOpDispatch } from './crashUI.js';
5
+ import { addNavigationEventListeners, } from './browserListeners.js';
16
6
  import { deepFreeze } from './deepFreeze.js';
17
- import { PreserveModelMessage, RequestModelMessage, RestoreModelMessage, } from './hmrProtocol.js';
7
+ import { makeDevToolsIntegration } from './devToolsIntegration.js';
8
+ import { createDuplicateIdScanner } from './duplicateIdScanner.js';
9
+ import { preserveModel } from './hmrModelBridge.js';
18
10
  import { preserveScrollPosition, restorePreservedScrollPosition, } from './hmrScroll.js';
11
+ import { makePortChannels, validatePorts, } from './hostConnector.js';
12
+ import { resolveHydrationHandoff, } from './hydrationHandoff.js';
13
+ import { forkManagedResourceFibers } from './managedResourceFibers.js';
14
+ import { makeMessageQueue } from './messageQueue.js';
19
15
  import { makePreserveScheduler } from './preserveScheduler.js';
20
- import { __decideViewTransition, __resolveStartViewTransition, __silenceViewTransitionRejections, } from './viewTransition.js';
21
- const toCommandRecord = (command) => command.args !== undefined
22
- ? { name: command.name, args: command.args }
23
- : { name: command.name };
24
- let registeredDevToolsOverlay;
25
- /** Registers the overlay supplied by the Foldkit Vite plugin. */
26
- export const __setDevToolsOverlay = (overlay) => {
27
- registeredDevToolsOverlay = overlay;
28
- };
29
- const DEFAULT_DEV_TOOLS_SHOW = 'Development';
30
- const DEFAULT_DEV_TOOLS_POSITION = 'BottomRight';
31
- const DEFAULT_DEV_TOOLS_MODE = 'TimeTravel';
32
- const resolveDevToolsMode = (config) => {
33
- if (typeof config === 'string') {
34
- return config;
35
- }
36
- else {
37
- return import.meta.hot ? config.development : config.production;
38
- }
39
- };
40
- const DEV_TOOLS_MAX_ENTRIES_MIN = 20;
41
- const DEV_TOOLS_MAX_ENTRIES_MAX = 500;
42
- const DEV_TOOLS_KEYFRAME_INTERVAL_MIN = 1;
43
- /** Phase names measured by the slow warning runtime option. */
44
- export const SlowPhase = Schema.Literals([
45
- 'Update',
46
- 'View',
47
- 'Patch',
48
- 'SubscriptionDependencies',
49
- ]);
50
- const DEFAULT_SLOW_SHOW = 'Development';
51
- const DEFAULT_SLOW_VIEW_THRESHOLD_MS = 16;
52
- const DEFAULT_SLOW_UPDATE_THRESHOLD_MS = 4;
53
- const DEFAULT_SLOW_PATCH_THRESHOLD_MS = 8;
54
- const DEFAULT_SLOW_SUBSCRIPTION_DEPENDENCIES_THRESHOLD_MS = 2;
55
- const ALL_SLOW_PHASES = [
56
- 'Update',
57
- 'View',
58
- 'Patch',
59
- 'SubscriptionDependencies',
60
- ];
61
- const resolveSlowPhase = (isMeasured, thresholdMs, onSlow) => Option.liftPredicate({
62
- thresholdMs,
63
- onSlow,
64
- }, () => isMeasured);
65
- export const __resolveSlowConfig = (slow, isSlowVisible) => {
66
- const maybeSlowConfig = Match.value(slow).pipe(Match.withReturnType(), Match.when(false, () => Option.none()), Match.when(Predicate.isUndefined, () => Option.some({})), Match.orElse(config => Option.some(config)));
67
- return pipe(maybeSlowConfig, Option.filter(config => isSlowVisible(config.show ?? DEFAULT_SLOW_SHOW)), Option.map(config => {
68
- const onSlow = config.onSlow ?? defaultSlowCallback;
69
- const measuredPhases = config.measuredPhases ?? ALL_SLOW_PHASES;
70
- const isPhaseMeasured = (phase) => Array.contains(measuredPhases, phase);
71
- return {
72
- view: resolveSlowPhase(isPhaseMeasured('View'), config.thresholdOverrides?.View ?? DEFAULT_SLOW_VIEW_THRESHOLD_MS, onSlow),
73
- update: resolveSlowPhase(isPhaseMeasured('Update'), config.thresholdOverrides?.Update ?? DEFAULT_SLOW_UPDATE_THRESHOLD_MS, onSlow),
74
- patch: resolveSlowPhase(isPhaseMeasured('Patch'), config.thresholdOverrides?.Patch ?? DEFAULT_SLOW_PATCH_THRESHOLD_MS, onSlow),
75
- subscriptionDependencies: resolveSlowPhase(isPhaseMeasured('SubscriptionDependencies'), config.thresholdOverrides?.SubscriptionDependencies ??
76
- DEFAULT_SLOW_SUBSCRIPTION_DEPENDENCIES_THRESHOLD_MS, onSlow),
77
- };
78
- }));
79
- };
80
- const measureSlowPhase = (maybeConfig, run) => {
81
- if (Option.isSome(maybeConfig)) {
82
- const start = performance.now();
83
- const result = run();
84
- return [result, Option.some(performance.now() - start)];
85
- }
86
- else {
87
- return [run(), Option.none()];
88
- }
89
- };
90
- const reportSlowPhase = (maybeConfig, maybeDurationMs, makeContext) => {
91
- if (Option.isSome(maybeConfig)) {
92
- const { thresholdMs, onSlow } = maybeConfig.value;
93
- const maybeExceededDuration = Option.filter(maybeDurationMs, durationMs => durationMs > thresholdMs);
94
- if (Option.isSome(maybeExceededDuration)) {
95
- onSlow(makeContext(maybeExceededDuration.value, thresholdMs));
96
- }
97
- }
98
- };
99
- const messageTag = (rawMessage) => pipe(rawMessage, Option.liftPredicate(Predicate.isObject), Option.flatMap(Record.get('_tag')), Option.match({
100
- onNone: () => 'unknown',
101
- onSome: String,
102
- }));
103
- const optionMessageTrigger = (maybeMessage) => Option.match(maybeMessage, {
104
- onNone: () => 'init',
105
- onSome: messageTag,
106
- });
107
- const TUNING_HINT = 'Set slow.thresholdOverrides to change budgets or pass slow: false to disable warnings.';
108
- export const defaultSlowCallback = (context) => {
109
- const { durationMs, thresholdMs: budget } = context;
110
- const duration = durationMs.toFixed(1);
111
- const summary = Match.value(context).pipe(Match.tagsExhaustive({
112
- View: ({ message }) => `Slow view: ${duration}ms (budget: ${budget}ms), triggered by ${optionMessageTrigger(message)}. Keep render-only work in the view path and memoize expensive subtrees with createLazy or createKeyedLazy.`,
113
- Update: ({ message }) => `Slow update: ${duration}ms (budget: ${budget}ms), triggered by ${messageTag(message)}. Inspect the triggering Message branch; move render-only derivations to memoized views and keep update focused on state transitions.`,
114
- Patch: ({ message }) => `Slow patch: ${duration}ms (budget: ${budget}ms), triggered by ${optionMessageTrigger(message)}. Key mapped lists by stable ids, split large views, or memoize stable subtrees with createLazy.`,
115
- SubscriptionDependencies: ({ subscriptionKey }) => `Slow subscription dependencies: ${duration}ms (budget: ${budget}ms) for subscription "${subscriptionKey}". Keep modelToDependencies a cheap projection from modeled fields; avoid scans, sorting, serialization, and large dependency objects.`,
116
- }));
117
- const maybeRawMessage = Match.value(context).pipe(Match.withReturnType(), Match.tagsExhaustive({
118
- Update: ({ message }) => Option.some(message),
119
- View: ({ message }) => message,
120
- Patch: ({ message }) => message,
121
- SubscriptionDependencies: () => Option.none(),
122
- }));
123
- console.warn(`[foldkit] ${summary} ${TUNING_HINT}`, context, ...Option.toArray(maybeRawMessage));
124
- };
125
- /** Dev-only scan for duplicate DOM ids within the Foldkit-rendered root.
126
- *
127
- * Duplicate ids are invalid HTML but browsers do not report them, so
128
- * `getElementById` / `querySelector` silently resolve to the first match and
129
- * focus or ARIA labelling can target the wrong element with no error. The
130
- * scan is scoped to `root` (not the whole document) so unrelated page ids do
131
- * not trigger it, warns rather than throws, and dedupes through `warnedIds`
132
- * so the same collision is reported at most once instead of every render. */
133
- const warnDuplicateIds = (root, warnedIds) => {
134
- if (!(root instanceof Element)) {
135
- return;
136
- }
137
- const seenIds = new Set();
138
- const duplicateIds = new Set();
139
- const elementsWithId = Array.fromIterable(root.querySelectorAll('[id]'));
140
- if (root.id !== '') {
141
- elementsWithId.unshift(root);
142
- }
143
- for (const element of elementsWithId) {
144
- const { id } = element;
145
- if (seenIds.has(id)) {
146
- duplicateIds.add(id);
147
- }
148
- else {
149
- seenIds.add(id);
150
- }
151
- }
152
- for (const id of duplicateIds) {
153
- if (!warnedIds.has(id)) {
154
- warnedIds.add(id);
155
- console.warn(`[foldkit] Duplicate DOM id "${id}" in the rendered tree. Ids must be ` +
156
- 'unique within the document; otherwise focus and ARIA labelling can ' +
157
- 'silently target the wrong element. Give each element a unique id.');
158
- }
159
- }
160
- };
161
- const DUPLICATE_ID_SCAN_INTERVAL_MS = 1000;
162
- /** Coalesces `warnDuplicateIds` scans so rapid successive renders trigger at
163
- * most one full-tree scan per `DUPLICATE_ID_SCAN_INTERVAL_MS`, bounding the
164
- * cost under high-frequency dev rendering such as animationFrame subscriptions
165
- * or per-keystroke input. A real duplicate id persists across renders, so
166
- * scanning the latest tree on a trailing timer never misses a genuine
167
- * collision, and the retained `warnedIds` keeps each id to a single warning.
168
- * Dev-only: the runtime both creates the scanner and calls `schedule` behind
169
- * `import.meta.hot`, so the scanner and its DOM scan tree-shake out of
170
- * production builds. */
171
- const createDuplicateIdScanner = () => {
172
- const warnedIds = new Set();
173
- let timeoutHandle;
174
- let latestRoot;
175
- const schedule = (root) => {
176
- latestRoot = root;
177
- if (timeoutHandle !== undefined) {
178
- return;
179
- }
180
- timeoutHandle = setTimeout(() => {
181
- timeoutHandle = undefined;
182
- warnDuplicateIds(latestRoot, warnedIds);
183
- }, DUPLICATE_ID_SCAN_INTERVAL_MS);
184
- };
185
- const cancel = () => {
186
- if (timeoutHandle !== undefined) {
187
- clearTimeout(timeoutHandle);
188
- timeoutHandle = undefined;
189
- }
190
- latestRoot = undefined;
191
- };
192
- return { schedule, cancel };
193
- };
194
- /** Effect service tag that provides message dispatching to the view layer. */
195
- export class Dispatch extends Context.Service()('@foldkit/Dispatch') {
196
- }
197
- const hydrationForRoot = (root, isFlagsRequired) => {
198
- const runtimeId = root.getAttribute(FOLDKIT_APP_ATTRIBUTE) ?? '';
199
- const flagsScripts = pipe(Array.fromIterable(document.querySelectorAll(`script[${FOLDKIT_FLAGS_ATTRIBUTE}]`)), Array.filter(script => script.getAttribute(FOLDKIT_FLAGS_ATTRIBUTE) === runtimeId));
200
- return { root, runtimeId, flagsScripts, isFlagsRequired };
201
- };
202
- // NOTE: hydration is scoped to the app's own stamped root so a server-rendered
203
- // app never adopts another app's DOM. A container that carries the stamp is that
204
- // root. A container that does not is a non-root element the caller resolved,
205
- // which happens when the rendered view has its own element with the container's
206
- // id (a descendant `id="root"`) and `getElementById` returned that inner element
207
- // instead of the stamped root above it. That inner element resolves to the app
208
- // root only when the page has exactly one stamped root and this container sits
209
- // inside it. A container outside that root is a refused handoff, not a fresh
210
- // application beside server markup that remains live. A null container is the
211
- // replace-parity case, where the server root took the placeholder's place and
212
- // `getElementById` no longer finds it, so the stamp is the only handle.
213
- // A runtime id names one application for the whole page: it pairs a root with
214
- // its Flags payload, and it keys the Model and scroll position hot reloading
215
- // preserves. Two roots sharing one are not two applications but one claimed
216
- // twice, so whichever boots second would read the other's handoff and restore
217
- // the other's Model. `injectIntoTemplate` refuses to build such a page; this is
218
- // the check for a page assembled some other way.
219
- //
220
- // Distinct ids do not make two hydrated applications independent, which is not
221
- // a supported arrangement: each page-owning application rewrites the document's
222
- // metadata and installs document-wide navigation listeners.
223
- const assertRuntimeIdsAreUnique = (stampedRoots) => {
224
- const seen = new Set();
225
- for (const root of stampedRoots) {
226
- const runtimeId = root.getAttribute(FOLDKIT_APP_ATTRIBUTE) ?? '';
227
- if (runtimeId === '') {
228
- containRefusedPage(root.ownerDocument);
229
- throw new Error('[foldkit] Found a server-rendered root with an empty ' +
230
- `\`${FOLDKIT_APP_ATTRIBUTE}\` stamp. A hydratable root must carry ` +
231
- 'a nonempty runtime id so the runtime can pair it with its Flags ' +
232
- 'payload and preserved HMR state.');
233
- }
234
- if (!root.ownerDocument.body?.contains(root)) {
235
- containRefusedPage(root.ownerDocument);
236
- throw new Error('[foldkit] Found a server-rendered root outside the document body. ' +
237
- 'Runtime.hydrate supports one page-owning application in the ' +
238
- 'document light DOM. Do not hydrate a root in the head, a shadow ' +
239
- 'tree, or a detached subtree.');
240
- }
241
- if (seen.has(runtimeId)) {
242
- containRefusedPage(root.ownerDocument);
243
- throw new Error(`[foldkit] Found more than one server-rendered root stamped ` +
244
- `"${runtimeId}". A runtime id names one application for the whole ` +
245
- 'page: it pairs a root with its Flags payload and keys the Model and ' +
246
- 'scroll position hot reloading preserves, so two roots sharing one ' +
247
- "would take each other's state. Remove the duplicate root. Foldkit " +
248
- 'hydrates one page-owning application per document.');
249
- }
250
- seen.add(runtimeId);
251
- }
252
- };
253
- const assertSinglePageApplication = (stampedRoots) => {
254
- if (stampedRoots.length > 1) {
255
- containRefusedPage(document);
256
- throw new Error('[foldkit] Found more than one page-owning application stamped with ' +
257
- `\`${FOLDKIT_APP_ATTRIBUTE}\`. Hydrating multiple applications in ` +
258
- 'one document is not supported: each application owns the document ' +
259
- 'metadata and installs document-wide navigation listeners. Render ' +
260
- 'one application per page.');
261
- }
262
- };
263
- // The reason this page cannot be adopted by this client, or `undefined` when
264
- // the two name the same deployment.
265
- //
266
- // The client's id is required and must be non-empty. An absent one would
267
- // otherwise equal the absent marker on a page served before build ids existed,
268
- // which reads a page from an unknown deployment as one of this build's own: the
269
- // exact case the id exists to refuse.
270
- const buildSkew = (root, buildId, runtimeId) => {
271
- if (!Predicate.isString(buildId) || buildId === '') {
272
- return new Error('[foldkit] Runtime.hydrate was given no build id. Hydration compares ' +
273
- 'the id the server stamped on the root with this client’s own before ' +
274
- 'it adopts any DOM, and without one a page from any deployment would ' +
275
- 'be adopted as this one. Pass ' +
276
- '`buildId: import.meta.env.FOLDKIT_BUILD_ID`, the same value the ' +
277
- 'server entry passes to `renderToString`.');
278
- }
279
- const servedBuild = root.getAttribute(HYDRATION_BUILD_ATTRIBUTE);
280
- if (servedBuild === buildId) {
281
- return undefined;
282
- }
283
- return new Error(`[foldkit] Runtime.hydrate found application "${runtimeId}" served by ` +
284
- `${servedBuild === null ? 'no known deployment' : `deployment "${servedBuild}"`}` +
285
- `, but this client belongs to deployment "${buildId}". Startup stops ` +
286
- 'here rather than reading a handoff written by other code: the Flags in ' +
287
- 'the page are that deployment’s, and this build could accept them while ' +
288
- 'every value in them means something else. Serve the page from the ' +
289
- 'running deployment, and keep stale HTML out of shared caches.');
290
- };
291
- const REFUSED_ATTRIBUTE = 'data-foldkit-refused';
292
- const REFUSAL_SHIELD_ATTRIBUTE = 'data-foldkit-refusal-shield';
293
- const refusalShields = new WeakMap();
294
- const REFUSAL_SHIELD_INPUT_EVENTS = [
295
- 'auxclick',
296
- 'click',
297
- 'contextmenu',
298
- 'dblclick',
299
- 'keydown',
300
- 'keypress',
301
- 'keyup',
302
- 'mousedown',
303
- 'mouseup',
304
- 'pointerdown',
305
- 'pointerup',
306
- 'touchend',
307
- 'touchstart',
308
- ];
309
- const REFUSAL_DOCUMENT_INPUT_EVENTS = ['keydown', 'keypress', 'keyup'];
310
- const preventRefusalShieldInteraction = (event) => {
311
- event.preventDefault();
312
- event.stopImmediatePropagation();
313
- };
314
- // NOTE: `showModal` throws when the owning document is not fully active. A
315
- // foreign or detached document still needs the original Foldkit refusal rather
316
- // than a browser exception from its containment UI.
317
- const tryOpenRefusalShieldAsModal = (shield) => {
318
- if (typeof shield.showModal !== 'function') {
319
- return false;
320
- }
321
- try {
322
- shield.showModal();
323
- return true;
324
- }
325
- catch {
326
- return false;
327
- }
328
- };
329
- const openRefusalShield = (shield) => {
330
- shield.inert = true;
331
- shield.setAttribute('inert', '');
332
- if (shield.open && typeof shield.close === 'function') {
333
- shield.close();
334
- }
335
- if (!tryOpenRefusalShieldAsModal(shield)) {
336
- shield.setAttribute('open', '');
337
- }
338
- shield.inert = false;
339
- shield.removeAttribute('inert');
340
- shield.focus({ preventScroll: true });
341
- };
342
- const installRefusalShield = (ownerDocument) => {
343
- const existing = refusalShields.get(ownerDocument);
344
- if (existing !== undefined && existing.isConnected) {
345
- openRefusalShield(existing);
346
- return;
347
- }
348
- const shield = ownerDocument.createElement('dialog');
349
- shield.setAttribute(REFUSAL_SHIELD_ATTRIBUTE, '');
350
- shield.setAttribute('aria-label', 'Page unavailable');
351
- shield.setAttribute('aria-modal', 'true');
352
- shield.setAttribute('closedby', 'none');
353
- shield.tabIndex = -1;
354
- shield.textContent =
355
- 'This page could not start safely. Reload to get the current version.';
356
- shield.style.alignItems = 'center';
357
- shield.style.background = 'rgba(15, 23, 42, 0.96)';
358
- shield.style.border = '0';
359
- shield.style.boxSizing = 'border-box';
360
- shield.style.color = 'white';
361
- shield.style.font = '600 1rem/1.5 system-ui, sans-serif';
362
- shield.style.display = 'grid';
363
- shield.style.height = '100vh';
364
- shield.style.inset = '0';
365
- shield.style.margin = '0';
366
- shield.style.maxHeight = 'none';
367
- shield.style.maxWidth = 'none';
368
- shield.style.overflow = 'hidden';
369
- shield.style.padding = '2rem';
370
- shield.style.position = 'fixed';
371
- shield.style.touchAction = 'none';
372
- shield.style.userSelect = 'none';
373
- shield.style.width = '100vw';
374
- shield.addEventListener('cancel', preventRefusalShieldInteraction);
375
- for (const eventName of REFUSAL_SHIELD_INPUT_EVENTS) {
376
- shield.addEventListener(eventName, preventRefusalShieldInteraction, {
377
- capture: true,
378
- passive: false,
379
- });
380
- }
381
- for (const eventName of REFUSAL_DOCUMENT_INPUT_EVENTS) {
382
- ownerDocument.addEventListener(eventName, preventRefusalShieldInteraction, {
383
- capture: true,
384
- passive: false,
385
- });
386
- }
387
- ownerDocument.documentElement.appendChild(shield);
388
- refusalShields.set(ownerDocument, shield);
389
- openRefusalShield(shield);
390
- };
391
- const containRefusedPage = (ownerDocument) => {
392
- const boundary = ownerDocument.body ?? ownerDocument.documentElement;
393
- boundary.inert = true;
394
- boundary.setAttribute('inert', '');
395
- boundary.setAttribute('aria-hidden', 'true');
396
- boundary.setAttribute(REFUSED_ATTRIBUTE, '');
397
- installRefusalShield(ownerDocument);
398
- };
399
- // Whether this page carries anything a Foldkit server render leaves behind. A
400
- // resolution failure on such a page is a refused handoff, and the markup is
401
- // contained; the same failure on a page with none of these markers is a client
402
- // application whose container never existed, where there is no server render to
403
- // refuse and nothing to take out of reach.
404
- const hasServerRenderedMarkup = (ownerDocument) => ownerDocument.querySelector(`[${FOLDKIT_APP_ATTRIBUTE}], [${HYDRATION_BUILD_ATTRIBUTE}], ` +
405
- `[${FOLDKIT_FLAGS_ATTRIBUTE}]`) !== null;
406
- const findDocumentHydration = (container, isFlagsRequired) => {
407
- const stampedRoots = Array.fromIterable(document.querySelectorAll(`[${FOLDKIT_APP_ATTRIBUTE}]`));
408
- assertRuntimeIdsAreUnique(stampedRoots);
409
- assertSinglePageApplication(stampedRoots);
410
- if (container !== null) {
411
- const stampedAncestor = container.closest(`[${FOLDKIT_APP_ATTRIBUTE}]`);
412
- if (container.hasAttribute(FOLDKIT_APP_ATTRIBUTE)) {
413
- const isDocumentRoot = Option.match(Array.head(stampedRoots), {
414
- onNone: () => false,
415
- onSome: root => root === container,
416
- });
417
- if (!isDocumentRoot) {
418
- containRefusedPage(container.ownerDocument);
419
- throw new Error('[foldkit] Runtime.hydrate received a stamped container that is ' +
420
- 'not the single server root in the document light DOM. Hydration ' +
421
- 'supports one page-owning application under the document body. ' +
422
- 'Do not hydrate a root in a shadow tree or detached subtree.');
423
- }
424
- return hydrationForRoot(container, isFlagsRequired);
425
- }
426
- return Array.match(stampedRoots, {
427
- onEmpty: () => {
428
- if (stampedAncestor === null) {
429
- return undefined;
430
- }
431
- containRefusedPage(container.ownerDocument);
432
- throw new Error('[foldkit] Runtime.hydrate received a container under a stamped ' +
433
- 'root outside the document body light DOM. Do not hydrate a root ' +
434
- 'in a shadow tree, detached subtree, or another document.');
435
- },
436
- onNonEmpty: roots => {
437
- const onlyRoot = Array.headNonEmpty(roots);
438
- if (stampedAncestor === onlyRoot) {
439
- return hydrationForRoot(onlyRoot, isFlagsRequired);
440
- }
441
- containRefusedPage(container.ownerDocument);
442
- throw new Error('[foldkit] Runtime.hydrate received a container outside the ' +
443
- "document's server-rendered application root. A page-owning " +
444
- 'application must adopt that single root rather than boot beside ' +
445
- 'server markup it does not own.');
446
- },
447
- });
448
- }
449
- return Option.match(Array.head(stampedRoots), {
450
- onNone: () => undefined,
451
- onSome: root => hydrationForRoot(root, isFlagsRequired),
452
- });
453
- };
454
- const makeHostConnector = () => {
455
- let isDisposed = false;
456
- let maybeDeliverInbound = Option.none();
457
- const pendingInboundSends = [];
458
- const listenersByPort = new Map();
459
- const sendInbound = (portName, port, value) => {
460
- if (isDisposed) {
461
- return Exit.void;
462
- }
463
- const decodeExit = Schema.decodeUnknownExit(port.schema)(value);
464
- Exit.match(decodeExit, {
465
- onFailure: cause => {
466
- console.error(`[foldkit] Inbound port "${portName}" rejected a value:`, Cause.squash(cause));
467
- },
468
- onSuccess: decodedValue => {
469
- Option.match(maybeDeliverInbound, {
470
- onNone: () => {
471
- pendingInboundSends.push({ port, value: decodedValue });
472
- },
473
- onSome: deliverInbound => deliverInbound(port, decodedValue),
474
- });
475
- },
476
- });
477
- return Exit.asVoid(decodeExit);
478
- };
479
- const addListener = (port, listener) => {
480
- if (isDisposed) {
481
- return Function.constVoid;
482
- }
483
- const listeners = listenersByPort.get(port) ?? new Set();
484
- listenersByPort.set(port, listeners);
485
- listeners.add(listener);
486
- return () => {
487
- listeners.delete(listener);
488
- };
489
- };
490
- // NOTE: delivery is deferred to a microtask so a host listener never runs
491
- // inside the runtime's Command fiber (a listener that synchronously calls
492
- // send or dispose must not re-enter the runtime), and so a host that
493
- // subscribes synchronously right after embed() returns still receives
494
- // emissions from init Commands.
495
- const deliverOutbound = (port, encodedValue) => {
496
- if (isDisposed) {
497
- return;
498
- }
499
- queueMicrotask(() => {
500
- if (isDisposed) {
501
- return;
502
- }
503
- const listeners = listenersByPort.get(port) ?? new Set();
504
- listeners.forEach(listener => {
505
- try {
506
- listener(encodedValue);
507
- }
508
- catch (listenerError) {
509
- console.error('[foldkit] An outbound port listener threw:', listenerError);
510
- }
511
- });
512
- });
513
- };
514
- const bind = (deliverInbound) => {
515
- maybeDeliverInbound = Option.some(deliverInbound);
516
- const flushedSends = pendingInboundSends.splice(0);
517
- flushedSends.forEach(({ port, value }) => deliverInbound(port, value));
518
- };
519
- const unbind = () => {
520
- maybeDeliverInbound = Option.none();
521
- };
522
- const dispose = () => {
523
- isDisposed = true;
524
- pendingInboundSends.length = 0;
525
- listenersByPort.forEach(listeners => listeners.clear());
526
- listenersByPort.clear();
527
- };
528
- return { sendInbound, addListener, deliverOutbound, bind, unbind, dispose };
529
- };
530
- const makePortChannels = (ports, maybeConnector) => {
531
- const inboundChannelsByPort = new Map();
532
- Object.values(ports.inbound ?? {}).forEach(port => {
533
- inboundChannelsByPort.set(port, __makeInboundChannel());
534
- });
535
- const outboundPorts = new Set(Object.values(ports.outbound ?? {}));
536
- const channels = {
537
- isConfigured: true,
538
- lookupInbound: port => Option.fromNullishOr(inboundChannelsByPort.get(port)),
539
- lookupOutbound: port => outboundPorts.has(port)
540
- ? Option.some(encodedValue => Option.match(maybeConnector, {
541
- onNone: Function.constVoid,
542
- onSome: connector => connector.deliverOutbound(port, encodedValue),
543
- }))
544
- : Option.none(),
545
- };
546
- const deliverInbound = (port, value) => {
547
- Option.match(Option.fromNullishOr(inboundChannelsByPort.get(port)), {
548
- onNone: Function.constVoid,
549
- onSome: channel => channel.deliver(value),
550
- });
551
- };
552
- return { channels, deliverInbound };
553
- };
554
- const validatePorts = (ports) => {
555
- const inboundEntries = Object.entries(ports.inbound ?? {});
556
- const outboundEntries = Object.entries(ports.outbound ?? {});
557
- const inboundNames = new Set(inboundEntries.map(([name]) => name));
558
- outboundEntries.forEach(([name]) => {
559
- if (inboundNames.has(name)) {
560
- throw new Error(`[foldkit] Port name "${name}" appears in both inbound and outbound. ` +
561
- 'Port names share one namespace on the EmbedHandle, so each name ' +
562
- 'must be unique across both records.');
563
- }
564
- });
565
- const seenPorts = new Set();
566
- const allEntries = [...inboundEntries, ...outboundEntries];
567
- allEntries.forEach(([name, port]) => {
568
- if (seenPorts.has(port)) {
569
- throw new Error(`[foldkit] The Port registered as "${name}" is also registered under ` +
570
- 'another name. Each entry in the ports record needs its own ' +
571
- 'Port.inbound or Port.outbound value.');
572
- }
573
- seenPorts.add(port);
574
- });
575
- };
576
- const runtimeInternals = new WeakMap();
577
- const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, isFlagsRequired, init, update, view, manageDocument, subscriptions, container, hydration, routing: routingConfig, crash, slow, viewTransition, freezeModel, preserveScroll, resources, managedResources, devTools, }) => {
578
- const isSlowVisible = (show) => Match.value(show).pipe(Match.when('Always', () => true), Match.when('Development', () => !!import.meta.hot), Match.exhaustive);
16
+ import { makeRenderer } from './renderer.js';
17
+ import { makeResourceProvider } from './resourceProvider.js';
18
+ import { makeRuntimeStatus } from './runtimeStatus.js';
19
+ import { __resolveSlowConfig, measureSlowPhase, reportSlowPhase, } from './slowPhase.js';
20
+ import { forkSubscriptionFibers } from './subscriptionFibers.js';
21
+ import { __resolveStartViewTransition, } from './viewTransition.js';
22
+ import { isVisible } from './visibility.js';
23
+ export const runtimeInternals = new WeakMap();
24
+ export const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, isFlagsRequired, init, update, view, manageDocument, subscriptions, container, hydration, routing: routingConfig, crash, slow, viewTransition, freezeModel, preserveScroll, resources, managedResources, devTools, }) => {
25
+ const isSlowVisible = (show) => isVisible(show, !!import.meta.hot);
579
26
  const htmlBuilder = htmlBuilderFor();
580
- const resolvedSlow = __resolveSlowConfig(slow, isSlowVisible);
581
- const resolvedSlowView = Option.flatMap(resolvedSlow, ({ view }) => view);
582
- const resolvedSlowUpdate = Option.flatMap(resolvedSlow, ({ update }) => update);
583
- const resolvedSlowPatch = Option.flatMap(resolvedSlow, ({ patch }) => patch);
584
- const resolvedSlowSubscriptionDependencies = Option.flatMap(resolvedSlow, ({ subscriptionDependencies }) => subscriptionDependencies);
27
+ const maybeResolvedSlow = __resolveSlowConfig(slow, isSlowVisible);
28
+ const maybeSlowView = Option.flatMap(maybeResolvedSlow, ({ view }) => view);
29
+ const maybeSlowUpdate = Option.flatMap(maybeResolvedSlow, ({ update }) => update);
30
+ const maybeSlowPatch = Option.flatMap(maybeResolvedSlow, ({ patch }) => patch);
31
+ const maybeSlowSubscriptionDependencies = Option.flatMap(maybeResolvedSlow, ({ subscriptionDependencies }) => subscriptionDependencies);
585
32
  // NOTE: detection sits inside the flatMap so it runs only for applications
586
33
  // that configured the option. Resolved eagerly it would read
587
34
  // `document.startViewTransition` and call `window.matchMedia` during
@@ -596,18 +43,6 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
596
43
  const duplicateIdScanner = import.meta.hot
597
44
  ? createDuplicateIdScanner()
598
45
  : undefined;
599
- const excludeFromHistoryTags = pipe(devTools ?? {}, Option.liftPredicate(config => config !== false), Option.flatMapNullishOr(config => config.excludeFromHistory), Option.match({
600
- onNone: () => new Set(),
601
- onSome: tags => new Set(tags),
602
- }));
603
- const devToolsMaxEntries = pipe(devTools ?? {}, Option.liftPredicate(config => config !== false), Option.flatMapNullishOr(config => config.maxEntries), Option.match({
604
- onNone: () => undefined,
605
- onSome: value => Math.max(DEV_TOOLS_MAX_ENTRIES_MIN, Math.min(DEV_TOOLS_MAX_ENTRIES_MAX, value)),
606
- }));
607
- const devToolsKeyframeInterval = pipe(devTools ?? {}, Option.liftPredicate(config => config !== false), Option.flatMapNullishOr(config => config.keyframeInterval), Option.match({
608
- onNone: () => undefined,
609
- onSome: value => Math.max(DEV_TOOLS_KEYFRAME_INTERVAL_MIN, Math.floor(value)),
610
- }));
611
46
  const maybeFreezeModel = (model) => isFreezeModelActive ? deepFreeze(model) : model;
612
47
  if (Predicate.isNotUndefined(ports)) {
613
48
  validatePorts(ports);
@@ -621,11 +56,6 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
621
56
  // the same signal. A commit in one embedded application must never wake a
622
57
  // `Render.afterCommit` awaiting inside another.
623
58
  const commitNotifier = createCommitNotifier();
624
- const settlePendingCommit = () => {
625
- if (commitNotifier.service.isCommitPending()) {
626
- commitNotifier.notifyCommitted();
627
- }
628
- };
629
59
  return Effect.scoped(Effect.gen(function* () {
630
60
  if (runtimeId === '') {
631
61
  return yield* Effect.die(new Error('[foldkit] Runtime container must have an `id` for HMR model preservation. ' +
@@ -638,21 +68,6 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
638
68
  // dispose does) interrupts them all and runs their finalizers. A
639
69
  // detached fork would outlive the runtime.
640
70
  const runtimeScope = yield* Effect.scope;
641
- // NOTE: `Effect.provide(effect, layer)` builds the Layer into a
642
- // scope that closes when the provided effect ends, so providing the
643
- // Layer per Command would construct and tear down every resource on
644
- // each invocation. Building once into `runtimeScope` through a
645
- // cached Effect is what makes `resources` long-lived: the first
646
- // Command or Subscription that runs triggers construction, every
647
- // later one shares the same built services, and release happens at
648
- // runtime teardown. The build is uninterruptible because
649
- // `Effect.cached` caches whatever Exit the first run produces:
650
- // dispose racing an in-flight build would otherwise cache an
651
- // interrupt, which every waiter would then surface as a crash.
652
- const maybeAcquireResourceContext = yield* Option.match(Option.fromNullishOr(resources), {
653
- onNone: () => Effect.succeed(Option.none()),
654
- onSome: resourceLayer => Effect.map(Effect.cached(Effect.uninterruptible(Layer.buildWithScope(resourceLayer, runtimeScope))), Option.some),
655
- });
656
71
  const maybePortChannels = pipe(Option.fromNullishOr(ports), Option.map(portsConfig => makePortChannels(portsConfig, maybeConnector)));
657
72
  yield* Option.match(Option.all({
658
73
  connector: maybeConnector,
@@ -661,167 +76,23 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
661
76
  onNone: () => Effect.void,
662
77
  onSome: ({ connector, portChannels }) => Effect.acquireRelease(Effect.sync(() => connector.bind(portChannels.deliverInbound)), () => Effect.sync(() => connector.unbind())),
663
78
  });
664
- // NOTE: One boundary registry per runtime instance, shared
665
- // across renders so Submodel wrap descriptors registered by
666
- // h.submodel persist between renders. The render function calls
667
- // `beginHtmlRender` at the start of each pass; wraps for
668
- // unmounted Submodels (e.g. an entry removed from a list) are
669
- // dropped from the registry via snabbdom destroy hooks attached
670
- // by `h.submodel` to each child vnode.
671
- const boundaryRegistry = createHtmlBoundaryRegistry();
672
- const managedResourceEntries = managedResources
673
- ? /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
674
- Record.toEntries(managedResources)
675
- : [];
676
- const managedResourceRefs = yield* Effect.forEach(managedResourceEntries, ([_key, config]) => Ref.make(Option.none()).pipe(Effect.map(ref => ({ config, ref }))));
677
- const mergeResourceIntoLayer = (layer, { config, ref }) => Layer.merge(layer, Layer.succeed(
678
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
679
- config.resource._tag, ref));
680
- const maybeManagedResourceLayer = Array.match(managedResourceRefs, {
681
- onEmpty: () => Option.none(),
682
- onNonEmpty: refs => Option.some(Array.reduce(refs,
683
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
684
- Layer.empty, mergeResourceIntoLayer)),
685
- });
686
- const interruptRegistry = __makeInterruptRegistry();
687
- const provideAllResources = (effect) => {
688
- const withResources = Option.match(maybeAcquireResourceContext, {
689
- onNone: () => effect,
690
- onSome: acquireResourceContext => Effect.flatMap(acquireResourceContext, resourceContext => Effect.provideContext(effect, resourceContext)),
691
- });
692
- const withManagedResources = Option.match(maybeManagedResourceLayer, {
693
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
694
- onNone: () => withResources,
695
- onSome: managedLayer =>
696
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
697
- Effect.provide(withResources, managedLayer),
698
- });
699
- const withPortChannels = Option.match(maybePortChannels, {
700
- onNone: () => withManagedResources,
701
- onSome: portChannels => Effect.provideService(withManagedResources, __CurrentPortChannels, portChannels.channels),
702
- });
703
- return Effect.provideService(withPortChannels, __CurrentInterruptRegistry, interruptRegistry);
704
- };
705
- // NOTE: Flags run through the same cached build that Commands and
706
- // Subscriptions use, rather than being handed the Layer again, so a
707
- // service needed both at startup and by a Command is constructed
708
- // once. An app without Flags never reaches it, which keeps the Layer
709
- // lazy when the first thing that needs it is a Command.
710
- //
711
- // NOTE: a Layer that fails to build is not fatal here. Flags resolve
712
- // before `init`, so there is no Model for a crash view to render
713
- // against and a failure escaping this point kills the app with a
714
- // blank container. Running Flags against an empty context instead
715
- // lets an app whose Flags never touch the Layer boot as it did
716
- // before Flags could consume `resources`: the cached failure then
717
- // surfaces at the first Command or Subscription, where `crashWith`
718
- // does render the crash view. Flags that do need the Layer still
719
- // fail here, and both causes are reported: the `Service not found`
720
- // defect the empty context produced is useless on its own, and the
721
- // build failure that explains it would be lost if it replaced the
722
- // Flags cause outright. Combining them also keeps a Flags Effect
723
- // that fails for its own unrelated reason visible instead of
724
- // attributing its defect to the Layer. Interrupts propagate
725
- // untouched on both sides, because dispose racing either the build
726
- // or the Flags run is not a failure to recover from, and
727
- // `Effect.catchCause` hands the handler interrupt causes too.
728
- const provideResources = (effect) => Option.match(maybeAcquireResourceContext, {
729
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
730
- onNone: () => effect,
731
- onSome: acquireResourceContext => Effect.matchCauseEffect(acquireResourceContext, {
732
- onFailure: buildCause => Cause.hasInterruptsOnly(buildCause)
733
- ? Effect.failCause(buildCause)
734
- : Effect.catchCause(Effect.provideContext(effect,
735
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
736
- Context.empty()), flagsCause => Cause.hasInterruptsOnly(flagsCause)
737
- ? Effect.failCause(flagsCause)
738
- : Effect.failCause(Cause.combine(buildCause, flagsCause))),
739
- onSuccess: resourceContext => Effect.provideContext(effect, resourceContext),
740
- }),
79
+ const { managedResourceRefs, provideAllResources, provideResources } = yield* makeResourceProvider({
80
+ resources,
81
+ managedResources,
82
+ runtimeScope,
83
+ maybePortChannels,
741
84
  });
742
- const maybeResolveFreshFlags = Option.orElse(Option.fromNullishOr(bootFlags), () => configuredFlags);
743
- const resolveFreshFlags = Option.match(maybeResolveFreshFlags, {
744
- onNone: () => isFlagsRequired
745
- ? Effect.die(new Error('[foldkit] This application declares Flags. Pass its ' +
746
- 'Flags Effect to Runtime.run or Runtime.embed.'))
747
- : /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
748
- Effect.succeed(undefined),
749
- onSome: provideResources,
750
- });
751
- // Every hydration refusal that knows which root it was going to adopt
752
- // contains that root first. The build id is one reason to refuse; a
753
- // missing, duplicated, malformed, or Schema-incompatible Flags payload
754
- // is another, and the page left behind is just as live in each case.
755
- const refuseHydration = (root, message, cause) => {
756
- containRefusedPage(root.ownerDocument);
757
- return Effect.die(cause === undefined
758
- ? new Error(message)
759
- : new Error(message, { cause }));
760
- };
761
- const decodeFlagsPayload = (payload, runtimeId, root) => Effect.try({
762
- try: () => {
763
- const parsedPayload = JSON.parse(payload);
764
- return pipe(
765
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
766
- Schema.toCodecJson(FlagsCodec), Schema.decodeUnknownSync, decode => decode(parsedPayload));
767
- },
768
- catch: cause => cause,
769
- }).pipe(Effect.catch(cause => refuseHydration(root, '[foldkit] Runtime.hydrate could not decode the server ' +
770
- `Flags payload for application "${runtimeId}". The HTML ` +
771
- 'and client bundle must use the same Flags Schema.', cause)));
772
- const maybeRequestedHydration = bootMode === 'Hydrate'
773
- ? Option.fromNullishOr(hydration)
774
- : Option.none();
775
- if (bootMode === 'Hydrate' && Option.isNone(maybeRequestedHydration)) {
776
- // A hydrating client that finds no stamped root will not adopt
777
- // whatever the page holds, so the page is contained whether or not the
778
- // caller named a container.
779
- containRefusedPage(container === null ? document : container.ownerDocument);
780
- return yield* Effect.die(new Error('[foldkit] Runtime.hydrate could not find a server-rendered ' +
781
- `root stamped with \`${FOLDKIT_APP_ATTRIBUTE}\`. Use ` +
782
- 'Runtime.run for a fresh client boot.'));
783
- }
784
- // The build the served page came from is settled here, before the Flags
785
- // payload text is accessed, parsed, or decoded, before `init` runs, and
786
- // therefore before any Command, Subscription, ManagedResource, or port
787
- // this boot would start. A page from another deployment carries that
788
- // deployment's Flags, which the current Schema may well accept while
789
- // every value in them means something else, so deferring the comparison
790
- // to the DOM patch lets stale data reach new code that already acted on
791
- // it.
792
- if (Option.isSome(maybeRequestedHydration)) {
793
- const skew = buildSkew(maybeRequestedHydration.value.root, buildId, maybeRequestedHydration.value.runtimeId);
794
- if (skew !== undefined) {
795
- containRefusedPage(maybeRequestedHydration.value.root.ownerDocument);
796
- return yield* Effect.die(skew);
797
- }
798
- }
799
- // NOTE: an HMR-restored Model wins over DOM adoption because the
800
- // server DOM reflects older code. The hydration handoff is still
801
- // required, but the restored Model gets a fresh patch against its
802
- // stamped root.
803
- const maybeHydrationRoot = Predicate.isUndefined(hmrModel)
804
- ? Option.map(maybeRequestedHydration, requestedHydration => requestedHydration.root)
805
- : Option.none();
806
- const maybeHydrationFlags = yield* Option.match(maybeRequestedHydration, {
807
- onNone: () => Effect.succeed(Option.none()),
808
- onSome: requestedHydration => Effect.map(requestedHydration.isFlagsRequired
809
- ? Array.match(requestedHydration.flagsScripts, {
810
- onEmpty: () => refuseHydration(requestedHydration.root, '[foldkit] Runtime.hydrate found application ' +
811
- `"${requestedHydration.runtimeId}" but its ` +
812
- 'server Flags payload is missing.'),
813
- onNonEmpty: ([payloadScript, ...remainingScripts]) => Array.isArrayNonEmpty(remainingScripts)
814
- ? refuseHydration(requestedHydration.root, '[foldkit] Runtime.hydrate found multiple ' +
815
- 'server Flags payloads for application ' +
816
- `"${requestedHydration.runtimeId}".`)
817
- : decodeFlagsPayload(payloadScript.textContent ?? '', requestedHydration.runtimeId, requestedHydration.root),
818
- })
819
- : /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
820
- Effect.succeed(undefined), Option.some),
821
- });
822
- const resolveFlags = Option.match(maybeHydrationFlags, {
823
- onNone: () => resolveFreshFlags,
824
- onSome: Effect.succeed,
85
+ const { maybeHydrationRoot, resolveFlags } = yield* resolveHydrationHandoff({
86
+ bootMode,
87
+ hydration,
88
+ bootFlags,
89
+ configuredFlags,
90
+ isFlagsRequired,
91
+ FlagsCodec,
92
+ hmrModel,
93
+ container,
94
+ buildId,
95
+ provideResources,
825
96
  });
826
97
  const ModelJsonCodec = Schema.toCodecJson(
827
98
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
@@ -881,267 +152,64 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
881
152
  }), handler => Effect.sync(() => hot.off('vite:beforeFullReload', handler)));
882
153
  }
883
154
  const schedulePreserveModel = (model) => hot ? preserveScheduler.schedule(model) : Effect.void;
884
- // NOTE: the dispatch hot path is plain JavaScript. A dispatched
885
- // Message is pushed onto a plain array and drained synchronously on
886
- // the spot, so update runs on the dispatching stack (for example, a
887
- // DOM event handler, a Command fiber completing, or a Subscription
888
- // emit) with no fiber hop in between. The drain guards against
889
- // re-entrancy: a Message dispatched mid-drain (for example, by an
890
- // update triggered from a synchronous Command) is queued and picked
891
- // up by the outer drain loop in arrival order, and a Message
892
- // dispatched while a render frame's patch is on the stack is
893
- // buffered until the frame completes.
894
- let pendingMessages = [];
895
- let isProcessingMessages = false;
896
- // NOTE: `isRenderFrameScheduled` clears when the frame callback
897
- // starts, which on the View Transition path is before the patch runs.
898
- // `commitNotifier` tracks the patch itself, so `Render.afterCommit`
899
- // waits for the commit rather than for the frame that scheduled it.
900
- let isRenderFrameScheduled = false;
901
- // NOTE: resume clears the DevTools store's pause flag before its frame
902
- // patches the live view. This distinguishes that intentional repaint
903
- // from an ordinary frame that was already queued when jumpTo installed
904
- // the historical view.
905
- let isLiveViewRestorePending = false;
906
- // NOTE: mirrors the old queue's boot behavior: a Message arriving
907
- // before boot completes (for example, a navigation event during an
908
- // async dev-mode boot step, or a boot-forked fiber emitting early)
909
- // is buffered, not processed. Processing against a partially
910
- // initialized runtime would race the init render, DevTools
911
- // recording, and Subscription attachment. The flag flips as the
912
- // last act of boot, which then drains the buffer. enqueueMessage
913
- // checks it directly, not just the drain: dispatch sources go live
914
- // mid-boot, before `drainPendingMessages` is initialized, and
915
- // calling it from a pre-boot dispatch would hit the temporal dead
916
- // zone.
917
- let isBootComplete = false;
918
- // NOTE: mirrors the old queue's post-interrupt behavior: a Message
919
- // dispatched after the runtime scope closed (for example, an
920
- // OnUnmount fired by the dispose teardown patch, or a stale DOM
921
- // handler) is dropped
922
- // instead of updating a disposed runtime. Set by a finalizer
923
- // registered at the end of boot, so it runs before
924
- // earlier-registered teardown (finalizers are LIFO).
925
- let isRuntimeDisposed = false;
926
- // NOTE: the differ fires destroy and insert hooks while `patch` is
927
- // on the stack, and both can dispatch synchronously (for example,
928
- // an OnUnmount dispatch, or a Mount stream's synchronous first
929
- // emission). Draining
930
- // inline would run update, and on a defect the crash renderer,
931
- // against a DOM the outer patch is still mutating. The frame
932
- // buffers such dispatches and drains them after it completes.
933
- let isRenderingFrame = false;
934
- // NOTE: a crash is terminal. The old runtime's drain fiber died on
935
- // the first defect, so nothing was processed after a crash; this
936
- // flag preserves that: the drain stops and later dispatches are
937
- // dropped, so update, Command forks, and DevTools recording all
938
- // stop with the crash view on screen.
939
- let isCrashed = false;
940
- const enqueueMessage = (message) => {
941
- if (isRuntimeDisposed || isCrashed) {
942
- return;
943
- }
944
- pendingMessages.push(message);
945
- if (!isBootComplete || isRenderingFrame) {
946
- return;
947
- }
948
- drainPendingMessages();
949
- };
950
- const enqueueMessageEffect = (message) => Effect.sync(() => enqueueMessage(message));
155
+ const status = makeRuntimeStatus();
156
+ // NOTE: `processMessagePlain` and `crashWith` are defined further
157
+ // down, so they are wrapped here instead of passed by reference. The
158
+ // queue has to exist before the navigation listeners attach below,
159
+ // and reading a `const` before its line has run throws. The type
160
+ // annotation is needed too: the `crashWith` wrapper calls the
161
+ // renderer's, and the renderer takes this queue, so without it
162
+ // TypeScript cannot infer either one.
163
+ const messageQueue = yield* makeMessageQueue({
164
+ status,
165
+ processMessage: message => processMessagePlain(message),
166
+ crashWith: (cause, maybeMessage) => crashWith(cause, maybeMessage),
167
+ });
168
+ const { enqueueMessage, enqueueMessageEffect, completeBoot } = messageQueue;
951
169
  const initModel = maybeFreezeModel(initModelRaw);
952
- const isInIframe = window.self !== window.top;
953
- 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 => ({
954
- position: config.position ?? DEFAULT_DEV_TOOLS_POSITION,
955
- mode: resolveDevToolsMode(config.mode ?? DEFAULT_DEV_TOOLS_MODE),
956
- maybeBanner: Option.fromNullishOr(config.banner),
957
- maybeOverlay: Option.fromNullishOr(registeredDevToolsOverlay),
958
- })));
959
170
  const modelPubSub = yield* PubSub.unbounded();
960
- let currentViewState = 'Live';
961
- const maybeViewStatePubSub = Option.isSome(resolvedDevTools)
962
- ? Option.some(yield* PubSub.unbounded({
963
- replay: 1,
964
- }))
965
- : Option.none();
966
- const viewStateChanges = Option.match(maybeViewStatePubSub, {
967
- onNone: () => liveViewStateChanges,
968
- onSome: viewStatePubSub => {
969
- PubSub.publishUnsafe(viewStatePubSub, currentViewState);
970
- return Stream.fromPubSub(viewStatePubSub);
971
- },
171
+ const devToolsIntegration = yield* makeDevToolsIntegration({
172
+ devTools,
173
+ update,
174
+ maybeFreezeModel,
175
+ enqueueMessageEffect,
972
176
  });
973
- const setViewState = (nextViewState) => {
974
- if (nextViewState === currentViewState) {
975
- return;
976
- }
977
- currentViewState = nextViewState;
978
- if (Option.isSome(maybeViewStatePubSub)) {
979
- PubSub.publishUnsafe(maybeViewStatePubSub.value, nextViewState);
980
- }
981
- };
177
+ const { installDevToolsStore, recordInit, recordMessage } = devToolsIntegration;
982
178
  if (import.meta.hot) {
983
179
  yield* Effect.addFinalizer(() => Effect.sync(() => duplicateIdScanner?.cancel()));
984
180
  }
985
181
  if (routingConfig) {
986
182
  yield* Effect.acquireRelease(Effect.sync(() => addNavigationEventListeners(enqueueMessage, routingConfig)), removeNavigationEventListeners => Effect.sync(() => removeNavigationEventListeners()));
987
183
  }
988
- // NOTE: the model and the current vnode are plain closure state.
989
- // The hot path reads and writes them directly; the cold paths that
990
- // run inside Effects (crash rendering, the dispose finalizer, the
991
- // replay render) read the same variables synchronously, so no Ref
992
- // is needed.
184
+ // NOTE: the Model is plain closure state. `processMessagePlain`
185
+ // reads and writes it directly, and the render side (the frame, and
186
+ // `crashWith` inside its `Effect.sync`) reads it synchronously
187
+ // through `readLiveModel`, so no Ref is needed.
993
188
  let liveModel = initModel;
994
- // NOTE: the Model behind the DOM currently on screen, which is what a
995
- // View Transition animates away from. Seeded with `initModel` because
996
- // the init render paints it, and advanced only where a render actually
997
- // commits. The `viewTransition` predicate never runs before a Message
998
- // has dirtied the Model, and the init render completes behind the boot
999
- // barrier, so this is always the model of a paint that happened.
1000
- let lastRenderedModel = initModel;
1001
- let maybePendingViewTransition = Option.none();
1002
- const skipPendingViewTransition = () => {
1003
- if (Option.isSome(maybePendingViewTransition)) {
1004
- const { value: pendingViewTransition } = maybePendingViewTransition;
1005
- // NOTE: cleared first. An implementation that runs the update
1006
- // callback synchronously would otherwise re-enter this.
1007
- maybePendingViewTransition = Option.none();
1008
- pendingViewTransition.update.isInvalidated = true;
1009
- try {
1010
- pendingViewTransition.handle.skipTransition();
1011
- }
1012
- catch {
1013
- // NOTE: skipping runs on teardown and crash paths, so a refusal
1014
- // must not propagate into them.
1015
- }
1016
- }
1017
- };
1018
- const vnodeSlot = { maybeCurrentVNode: Option.none() };
1019
- const patchRuntimeVNode = (maybeCurrentVNode, nextVNode, seen) => {
1020
- try {
1021
- return __patchVNode(maybeCurrentVNode, nextVNode, container, seen, patchedVNode => {
1022
- vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
1023
- });
1024
- }
1025
- catch (error) {
1026
- try {
1027
- const maybeRecoveryVNode = vnodeSlot.maybeCurrentVNode;
1028
- if (Option.isSome(maybeRecoveryVNode)) {
1029
- vnodeSlot.maybeCurrentVNode = Option.some(__recoverVNodeAfterPatchFailure(maybeRecoveryVNode.value));
1030
- }
1031
- }
1032
- finally {
1033
- flushReplayUnmountsAfterPatchFailure();
1034
- }
1035
- throw error;
1036
- }
1037
- };
1038
- // NOTE: consumed by the first render only. Set when this boot found
1039
- // an adoptable server-rendered root; the first patch then goes
1040
- // through `__hydrateVNode` instead of replacing the container.
1041
- let pendingHydrationRoot = Option.getOrNull(maybeHydrationRoot);
1042
- // NOTE: registered before any perpetual fiber is forked so it runs
1043
- // after they are interrupted (scope finalizers are LIFO). Patching to
1044
- // an empty tree fires snabbdom destroy hooks, which is what releases
1045
- // Mounts; swapping the placeholder for the original container leaves
1046
- // the host DOM as it was before the first render, ready for a fresh
1047
- // embed of the same container. Gated on interruption: that is the
1048
- // dispose path. A runtime that stops because it crashed completes
1049
- // normally after rendering the crash view, and the crash view must
1050
- // stay visible.
1051
- yield* Effect.addFinalizer(exit => Effect.gen(function* () {
1052
- if (!Exit.hasInterrupts(exit)) {
1053
- return;
1054
- }
1055
- const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
1056
- yield* Option.match(maybeCurrentVNode, {
1057
- onNone: () => Effect.void,
1058
- onSome: currentVNode => Effect.sync(() => {
1059
- const placeholderNode = __patchVNode(Option.some(currentVNode), null, container).elm;
1060
- if (placeholderNode && placeholderNode.parentNode) {
1061
- placeholderNode.parentNode.replaceChild(container, placeholderNode);
1062
- container.replaceChildren();
1063
- }
1064
- }),
1065
- });
1066
- }));
1067
- // NOTE: shared by every crash path: the init render, the plain
1068
- // message drain and render frame (which reach it through
1069
- // `Effect.runFork` from their catch blocks), and the Command and
1070
- // Subscription fibers (a Command's Effect and a Subscription's
1071
- // Stream are typed with a `never` error channel, so a cause
1072
- // escaping one can only be a `resources` Layer build failure or an
1073
- // escaped defect, both unrecoverable). Each path catches its own
1074
- // cause so a failure surfaces as the crash view instead of dying
1075
- // silently and leaving the DOM frozen at the last successful
1076
- // render. The first crash wins: concurrent Command fibers can fail
1077
- // on the same broken Layer, and only one should report and render.
1078
- const crashWith = (cause, maybeMessage) => Effect.sync(() => {
1079
- if (isCrashed) {
1080
- return;
1081
- }
1082
- isCrashed = true;
1083
- // NOTE: the crash view should appear at once, not animate in from
1084
- // a snapshot of the state that crashed.
1085
- skipPendingViewTransition();
1086
- const model = liveModel;
1087
- const squashed = Cause.squash(cause);
1088
- const error = squashed instanceof Error ? squashed : new Error(String(squashed));
1089
- renderCrashView({ error, model, message: maybeMessage }, crash, container, vnodeSlot, manageDocument);
1090
- settlePendingCommit();
189
+ // NOTE: the runtime context for OnMount forking and Command forking
190
+ // is captured once here; it is constant for the lifetime of the
191
+ // runtime.
192
+ const runtimeContext = yield* Effect.context();
193
+ const { crashWith, render, setLastDirtyMessage, scheduleRenderFrame, skipPendingViewTransition, devToolsRenderBridge, } = yield* makeRenderer({
194
+ status,
195
+ container,
196
+ view,
197
+ htmlBuilder,
198
+ manageDocument,
199
+ crash,
200
+ buildId,
201
+ initModel,
202
+ maybeHydrationRoot,
203
+ maybeSlowView,
204
+ maybeSlowPatch,
205
+ duplicateIdScanner,
206
+ maybeResolvedViewTransition,
207
+ commitNotifier,
208
+ runtimeContext,
209
+ readLiveModel: () => liveModel,
210
+ messageQueue,
211
+ devToolsIntegration,
1091
212
  });
1092
- // NOTE: drain-local state. Kept as plain closure variables instead
1093
- // of `Ref`s because nothing else reads or writes them concurrently,
1094
- // and JS's single-threaded model already orders writes against
1095
- // subsequent reads. `currentMessage` is read by the crash handler.
1096
- let currentMessage = Option.none();
1097
- let maybeLastDirtyMessage = Option.none();
1098
- // NOTE: the DevTools store is installed at most once during boot and
1099
- // never replaced. Caching it in a closure variable avoids a
1100
- // `Ref.get` on every message and on every render frame (the
1101
- // store powers the pause check). Plain `null` rather than `Option`:
1102
- // the hot path only ever presence-checks it, and the check should
1103
- // stay a bare comparison.
1104
- let devToolsStore = null;
1105
- const dispatchSync = (message) => {
1106
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1107
- enqueueMessage(message);
1108
- };
1109
- const dispatchAsync = (message) =>
1110
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1111
- enqueueMessageEffect(message);
1112
- const dispatch = { dispatchAsync, dispatchSync };
1113
- const mountRuntime = MountRuntime.of({
1114
- captureViewStateChanges: () => Stream.concat(Stream.make(currentViewState), viewStateChanges).pipe(Stream.changes),
1115
- });
1116
- const isPausedNow = () => devToolsStore !== null &&
1117
- SubscriptionRef.getUnsafe(devToolsStore.stateRef).isPaused;
1118
- // NOTE: recording is gated on the DevTools store because the store
1119
- // is the only consumer. Without the gate every Mount start and end
1120
- // in a production frame would allocate a record just to be sliced
1121
- // and dropped.
1122
- const mountStartBuffer = [];
1123
- const mountEndBuffer = [];
1124
- const mountTracker = {
1125
- started: (name, args) => {
1126
- if (devToolsStore === null) {
1127
- return;
1128
- }
1129
- mountStartBuffer.push(args === undefined ? { name } : { name, args });
1130
- },
1131
- ended: (name, args) => {
1132
- if (devToolsStore === null) {
1133
- return;
1134
- }
1135
- mountEndBuffer.push(args === undefined ? { name } : { name, args });
1136
- },
1137
- };
1138
- const drainMountEvents = () => {
1139
- const starts = mountStartBuffer.slice();
1140
- const ends = mountEndBuffer.slice();
1141
- mountStartBuffer.length = 0;
1142
- mountEndBuffer.length = 0;
1143
- return { starts, ends };
1144
- };
1145
213
  // NOTE: the fork is deferred one microtask so a Command's Effect
1146
214
  // never begins on the dispatching stack. Commands are facts from
1147
215
  // outside the update loop; their results always arrive
@@ -1152,9 +220,9 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
1152
220
  // `runSyncWith` injects a temporary synchronous scheduler into the
1153
221
  // fiber context, the child would inherit it, and every later yield
1154
222
  // in the Command (for example, an op-budget suspension, or a
1155
- // Stream step) would
1156
- // reschedule through clamped `setTimeout` instead of the browser
1157
- // microtask scheduler carried by `runtimeContextForCommands`.
223
+ // Stream step) would reschedule through clamped `setTimeout`
224
+ // instead of the browser microtask scheduler carried by
225
+ // `runtimeContext`.
1158
226
  const forkCommand = (command, message) => {
1159
227
  queueMicrotask(() => {
1160
228
  // NOTE: `isCrashed` as well as `isRuntimeDisposed`. A crash is
@@ -1164,21 +232,21 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
1164
232
  // check its effect would run behind the crash view, contradicting
1165
233
  // the crash-terminality contract. `crashWith` sets `isCrashed`
1166
234
  // synchronously, so it is already set by the time this runs.
1167
- if (isRuntimeDisposed || isCrashed) {
235
+ if (status.isRuntimeDisposed || status.isCrashed) {
1168
236
  return;
1169
237
  }
1170
- Effect.runForkWith(runtimeContextForCommands)(Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
238
+ Effect.runForkWith(runtimeContext)(Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
1171
239
  attributes: command.args ?? {},
1172
240
  }), provideAllResources, Effect.flatMap(enqueueMessageEffect), Effect.catchCause(cause => crashWith(cause, message)))));
1173
241
  });
1174
242
  };
1175
243
  const processMessagePlain = (message) => {
1176
244
  const currentModel = liveModel;
1177
- const [messageUpdate, maybeUpdateDuration] = measureSlowPhase(resolvedSlowUpdate, () => update(currentModel, message));
245
+ const [messageUpdate, maybeUpdateDuration] = measureSlowPhase(maybeSlowUpdate, () => update(currentModel, message));
1178
246
  const nextModelRaw = messageUpdate.model;
1179
247
  const commands = messageUpdate.commands ?? [];
1180
248
  const nextModel = maybeFreezeModel(nextModelRaw);
1181
- reportSlowPhase(resolvedSlowUpdate, maybeUpdateDuration, (durationMs, thresholdMs) => ({
249
+ reportSlowPhase(maybeSlowUpdate, maybeUpdateDuration, (durationMs, thresholdMs) => ({
1182
250
  _tag: 'Update',
1183
251
  previousModel: currentModel,
1184
252
  nextModel,
@@ -1188,7 +256,7 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
1188
256
  }));
1189
257
  if (currentModel !== nextModel) {
1190
258
  liveModel = nextModel;
1191
- maybeLastDirtyMessage = Option.some(message);
259
+ setLastDirtyMessage(message);
1192
260
  PubSub.publishUnsafe(modelPubSub, nextModel);
1193
261
  if (import.meta.hot) {
1194
262
  Effect.runSync(schedulePreserveModel(nextModel));
@@ -1202,307 +270,9 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
1202
270
  command, Option.some(message));
1203
271
  }
1204
272
  }
1205
- // NOTE: store writes go through `Effect.runFork`, not
1206
- // `Effect.runSync`. Both complete inline when the store's state
1207
- // Ref is uncontended (the always case on this path), but a
1208
- // DevTools fiber holding the Ref's permit across a yield would
1209
- // make `runSync` throw and crash the app; `runFork` parks and
1210
- // finishes the write when the permit frees, and the Ref's FIFO
1211
- // permit queue preserves write order.
1212
- if (devToolsStore !== null) {
1213
- const store = devToolsStore;
1214
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1215
- const tag = message._tag;
1216
- const isModelChanged = currentModel !== nextModel;
1217
- if (!excludeFromHistoryTags.has(tag)) {
1218
- Effect.runFork(store.recordMessage(
1219
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1220
- message, currentModel, nextModel, Array.map(
1221
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1222
- commands, toCommandRecord), isModelChanged));
1223
- }
1224
- else if (isModelChanged) {
1225
- Effect.runFork(store.updateLatestModel(nextModel));
1226
- }
1227
- }
273
+ recordMessage(message, currentModel, nextModel, commands);
1228
274
  };
1229
- // NOTE: escape hatch for synchronous bursts, so the page keeps
1230
- // painting under pathological load (for example, a fiber
1231
- // dispatching thousands of Messages in one task, or a fully
1232
- // synchronous Command chain). Bursts
1233
- // arrive as many single-Message drains within one browser task, so
1234
- // the budget is cumulative across drains: it accumulates processing
1235
- // time and resets when the browser demonstrably got control back (a
1236
- // render frame ran, or the gap since the last drain exceeds the
1237
- // budget). Once over budget, processing defers to a MessageChannel
1238
- // tick, which starts a new task so a pending frame can paint.
1239
- // setTimeout(0) would be clamped to 4ms+; MessageChannel delivers in
1240
- // ~0.5ms. The normal path pays two clock reads per drain.
1241
- let syncWorkMsSinceYield = 0;
1242
- let lastDrainEndedAt = 0;
1243
- let isDrainDeferredToNextTask = false;
1244
- let maybeDeferredDrainChannel = null;
1245
- const scheduleDeferredDrain = () => {
1246
- if (maybeDeferredDrainChannel === null) {
1247
- maybeDeferredDrainChannel = new MessageChannel();
1248
- maybeDeferredDrainChannel.port2.onmessage = () => {
1249
- isDrainDeferredToNextTask = false;
1250
- syncWorkMsSinceYield = 0;
1251
- drainPendingMessages();
1252
- };
1253
- }
1254
- isDrainDeferredToNextTask = true;
1255
- maybeDeferredDrainChannel.port1.postMessage(null);
1256
- };
1257
- yield* Effect.addFinalizer(() => Effect.sync(() => {
1258
- if (maybeDeferredDrainChannel !== null) {
1259
- maybeDeferredDrainChannel.port1.close();
1260
- maybeDeferredDrainChannel.port2.close();
1261
- maybeDeferredDrainChannel = null;
1262
- }
1263
- }));
1264
- const drainPendingMessages = () => {
1265
- if (!isBootComplete ||
1266
- isProcessingMessages ||
1267
- isRenderingFrame ||
1268
- isDrainDeferredToNextTask ||
1269
- isRuntimeDisposed ||
1270
- isCrashed) {
1271
- return;
1272
- }
1273
- const drainStartedAt = performance.now();
1274
- if (drainStartedAt - lastDrainEndedAt > DRAIN_BUDGET_MS) {
1275
- syncWorkMsSinceYield = 0;
1276
- }
1277
- if (syncWorkMsSinceYield > DRAIN_BUDGET_MS) {
1278
- scheduleDeferredDrain();
1279
- return;
1280
- }
1281
- isProcessingMessages = true;
1282
- try {
1283
- while (pendingMessages.length > 0) {
1284
- const batch = pendingMessages;
1285
- pendingMessages = [];
1286
- for (let index = 0; index < batch.length; index++) {
1287
- const message = batch[index];
1288
- currentMessage = Option.some(message);
1289
- processMessagePlain(message);
1290
- const hasRemainingWork = index + 1 < batch.length || pendingMessages.length > 0;
1291
- if (hasRemainingWork &&
1292
- syncWorkMsSinceYield + (performance.now() - drainStartedAt) >
1293
- DRAIN_BUDGET_MS) {
1294
- // NOTE: unprocessed batch Messages arrived before
1295
- // anything in pendingMessages, so they go back to the
1296
- // front to keep arrival order.
1297
- pendingMessages = batch
1298
- .slice(index + 1)
1299
- .concat(pendingMessages);
1300
- scheduleDeferredDrain();
1301
- return;
1302
- }
1303
- }
1304
- }
1305
- }
1306
- catch (error) {
1307
- Effect.runFork(crashWith(Cause.die(error), currentMessage));
1308
- }
1309
- finally {
1310
- const drainEndedAt = performance.now();
1311
- syncWorkMsSinceYield += drainEndedAt - drainStartedAt;
1312
- lastDrainEndedAt = drainEndedAt;
1313
- isProcessingMessages = false;
1314
- }
1315
- };
1316
- // NOTE: `dispatchService` defaults to live dispatch but is overridable
1317
- // so a time-travel render can bind both declarative handlers and newly
1318
- // acquired Mounts to `noOpDispatch`. A live Mount keeps its dispatcher
1319
- // across replay, while a replay-created Mount stays muted until a live
1320
- // resume patch releases it and starts the live action. This preserves
1321
- // valid async results from live Mounts without granting a historical
1322
- // acquisition access to the live Model.
1323
- const render = (model, message, dispatchService = dispatch, renderMode = 'Live') => Effect.gen(function* () {
1324
- isRenderingFrame = true;
1325
- const runtimeContext = yield* Effect.context();
1326
- const maybeLiveRender = Option.liftPredicate(renderMode, mode => mode === 'Live');
1327
- if (renderMode === 'Replay') {
1328
- beginReplayHtmlRender();
1329
- }
1330
- const maybeLiveSlowView = Option.flatMap(maybeLiveRender, () => resolvedSlowView);
1331
- const maybeLiveSlowPatch = Option.flatMap(maybeLiveRender, () => resolvedSlowPatch);
1332
- const [nextDocument, maybeViewDuration] = measureSlowPhase(maybeLiveSlowView, () => {
1333
- beginHtmlRender(boundaryRegistry);
1334
- setHtmlRuntime(dispatchService.dispatchSync, runtimeContext, boundaryRegistry, renderMode);
1335
- try {
1336
- return view(model, htmlBuilder);
1337
- }
1338
- finally {
1339
- clearHtmlRuntime();
1340
- }
1341
- });
1342
- const nextVNode = nextDocument.body;
1343
- reportSlowPhase(maybeLiveSlowView, maybeViewDuration, (durationMs, thresholdMs) => ({
1344
- _tag: 'View',
1345
- model,
1346
- message,
1347
- durationMs,
1348
- thresholdMs,
1349
- }));
1350
- const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
1351
- const [patchedVNode, maybePatchDuration] = yield* Effect.sync(() => measureSlowPhase(maybeLiveSlowPatch, () => {
1352
- if (Option.isNone(maybeCurrentVNode) &&
1353
- pendingHydrationRoot !== null) {
1354
- const hydrationRoot = pendingHydrationRoot;
1355
- pendingHydrationRoot = null;
1356
- // NOTE: strip the stamp before the patch, not after, so the
1357
- // patch is the sole owner of the root's attributes. It has
1358
- // already served its purpose of locating the root, and
1359
- // removing it after would delete a `data-foldkit-app` the view
1360
- // itself declares, which a later equal-vnode patch would not
1361
- // restore. Removing it here also stops a later boot on the same
1362
- // container (a dispose-then-embed remount) from re-detecting
1363
- // this now-consumed root as hydratable.
1364
- hydrationRoot.removeAttribute(FOLDKIT_APP_ATTRIBUTE);
1365
- // An empty id reaches the adoption step's own check as a
1366
- // value that matches nothing. Boot already refused a
1367
- // hydration without an id, so this stands in only for a
1368
- // caller that reached here another way.
1369
- return __hydrateVNode(hydrationRoot, nextVNode, boundaryRegistry.dedupeSeen, buildId ?? '');
1370
- }
1371
- return patchRuntimeVNode(maybeCurrentVNode, nextVNode, boundaryRegistry.dedupeSeen);
1372
- }));
1373
- vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
1374
- reportSlowPhase(maybeLiveSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
1375
- _tag: 'Patch',
1376
- model,
1377
- message,
1378
- durationMs,
1379
- thresholdMs,
1380
- }));
1381
- if (manageDocument) {
1382
- yield* Effect.sync(() => applyDocumentMetadata(nextDocument, patchedVNode.elm));
1383
- }
1384
- if (import.meta.hot) {
1385
- yield* Effect.sync(() => duplicateIdScanner?.schedule(patchedVNode.elm));
1386
- }
1387
- }).pipe(Effect.ensuring(Effect.sync(() => {
1388
- isRenderingFrame = false;
1389
- endReplayHtmlRender();
1390
- drainPendingMessages();
1391
- })), Effect.provideService(Dispatch, dispatchService), Effect.provideService(MountTracker, mountTracker), Effect.provideService(MountRuntime, mountRuntime));
1392
- if (Option.isSome(resolvedDevTools)) {
1393
- const { position, mode, maybeBanner, maybeOverlay } = resolvedDevTools.value;
1394
- // NOTE: when excludeFromHistory is active, the runtime drops
1395
- // excluded Messages from the recorded history. Replay walks the
1396
- // recorded entries forward from the nearest keyframe. With
1397
- // exclusion, the dropped Messages aren't in that walk, so any
1398
- // cumulative state they would have produced is missing from the
1399
- // replayed model. Setting keyframeInterval to 1 stores a full
1400
- // snapshot on every recorded entry, so time-travel becomes a
1401
- // direct lookup that reflects the real live state at the moment
1402
- // the entry was recorded.
1403
- const isExcludingMessages = excludeFromHistoryTags.size > 0;
1404
- const store = yield* createDevToolsStore({
1405
- /* eslint-disable @typescript-eslint/consistent-type-assertions */
1406
- replay: (model, message) => {
1407
- const replayUpdate = update(model, message);
1408
- return maybeFreezeModel(replayUpdate.model);
1409
- },
1410
- /* eslint-enable @typescript-eslint/consistent-type-assertions */
1411
- // NOTE: passes `noOpDispatch` so declarative handlers and Mounts
1412
- // acquired by the replay cannot reach the live Model. Their
1413
- // fibers stay alive and can observe view-state changes while the
1414
- // historical view owns them. If resume reuses such an element,
1415
- // OnMount releases the replay acquisition before starting the
1416
- // live action. Also discards mount events fired during the render
1417
- // so they don't get attributed to the next user-initiated dispatch.
1418
- render: model => Effect.gen(function* () {
1419
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1420
- const replayedModel = model;
1421
- const previousRenderedModel = lastRenderedModel;
1422
- // NOTE: a Mount surviving from the live view must observe
1423
- // Paused before the historical patch can expose different
1424
- // DOM. Mounts inserted by that patch capture this state when
1425
- // acquired, so asynchronous setup cannot skip Paused even if
1426
- // it consumes the Stream only after the live view returns.
1427
- setViewState('Paused');
1428
- // NOTE: a transition still animating belongs to the live
1429
- // state this replay is about to paint over. Left running it
1430
- // animates a dead snapshot across the replayed DOM.
1431
- skipPendingViewTransition();
1432
- const replayRenderExit = yield* Effect.exit(render(replayedModel, Option.none(), noOpDispatch, 'Replay'));
1433
- if (Exit.isFailure(replayRenderExit)) {
1434
- drainMountEvents();
1435
- if (isPausedNow()) {
1436
- // NOTE: the failed patch may already have changed the
1437
- // DOM. Repaint the Model at the store's previous paused
1438
- // index before returning the failure. If that Model no
1439
- // longer renders either, resume the store so its normal
1440
- // live frame becomes the single recovery path.
1441
- const rollbackExit = yield* Effect.exit(render(previousRenderedModel, Option.none(), noOpDispatch, 'Replay'));
1442
- drainMountEvents();
1443
- if (Exit.isFailure(rollbackExit) &&
1444
- devToolsStore !== null) {
1445
- yield* devToolsStore.resume;
1446
- }
1447
- }
1448
- else {
1449
- // NOTE: a failed first jump leaves the store live. Keep
1450
- // Mounts Paused through the recovery patch, which
1451
- // publishes Live only after the live DOM is restored.
1452
- yield* Effect.sync(() => scheduleRenderFrame(true));
1453
- }
1454
- return yield* Effect.failCause(replayRenderExit.cause);
1455
- }
1456
- drainMountEvents();
1457
- // NOTE: a replay paints a past Model, so it owns the DOM on
1458
- // screen until the next live frame. Leaving
1459
- // `lastRenderedModel` on the pre-pause Model would hand the
1460
- // `viewTransition` predicate a `previousModel` describing a
1461
- // DOM that no longer exists, and the frame `resume`
1462
- // schedules would animate the wrong direction out of the
1463
- // wrong snapshot.
1464
- lastRenderedModel = replayedModel;
1465
- // NOTE: the Message that dirtied the pre-pause frame does not
1466
- // describe this repaint. Clearing it means the frame `resume`
1467
- // schedules renders plainly, matching the documented rule
1468
- // that time-travel never animates.
1469
- maybeLastDirtyMessage = Option.none();
1470
- }),
1471
- // NOTE: `resume` calls this after a jumpTo render attached DOM
1472
- // listeners to `noOpDispatch`. Scheduling a frame renders the
1473
- // live model with live dispatch and rebinds listeners.
1474
- markRenderPending: Effect.sync(() => scheduleRenderFrame(true)),
1475
- }, {
1476
- ...(devToolsKeyframeInterval !== undefined && {
1477
- keyframeInterval: devToolsKeyframeInterval,
1478
- }),
1479
- ...(devToolsMaxEntries !== undefined && {
1480
- maxEntries: devToolsMaxEntries,
1481
- }),
1482
- // NOTE: exclusion forces keyframeInterval to 1 regardless of any
1483
- // configured value, since excluded Messages are never replayed
1484
- // and a denser interval would leave gaps in the replayed model.
1485
- // Spread last so it wins over `keyframeInterval` above.
1486
- ...(isExcludingMessages && { keyframeInterval: 1 }),
1487
- });
1488
- devToolsStore = store;
1489
- // NOTE: init is recorded after the init render below, so the
1490
- // mount buffer reflects the Mounts that fired on the first paint.
1491
- yield* Option.match(maybeOverlay, {
1492
- onNone: () => Effect.void,
1493
- onSome: overlay => overlay(store, position, mode, maybeBanner),
1494
- });
1495
- if (import.meta.hot) {
1496
- const maybeMessageSchema = devTools !== undefined && devTools !== false
1497
- ? Option.fromNullishOr(devTools.Message)
1498
- : Option.none();
1499
- yield* startWebSocketBridge(store, import.meta.hot,
1500
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1501
- message => enqueueMessageEffect(message),
1502
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1503
- maybeMessageSchema);
1504
- }
1505
- }
275
+ yield* installDevToolsStore(devToolsRenderBridge);
1506
276
  const initRenderExit = yield* Effect.exit(render(initModel, Option.none()));
1507
277
  if (Exit.isFailure(initRenderExit)) {
1508
278
  yield* crashWith(initRenderExit.cause, Option.none());
@@ -1515,315 +285,26 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
1515
285
  if (isPreserveScrollActive) {
1516
286
  yield* restorePreservedScrollPosition(runtimeId);
1517
287
  }
1518
- const initMountEvents = drainMountEvents();
1519
- if (devToolsStore !== null) {
1520
- yield* devToolsStore.recordInit(initModel, Array.map(initCommands, toCommandRecord), initMountEvents.starts);
1521
- }
1522
- // NOTE: maybeLastDirtyMessage holds the most recent dirtying
1523
- // Message, so slow render-phase callbacks during high-rate bursts attribute
1524
- // to the last Message in the frame batch, not the specific one that
1525
- // pushed the view past threshold. Acceptable for a debug callback;
1526
- // full attribution would require correlating each message with its
1527
- // render contribution, which isn't worth the complexity.
1528
- // NOTE: render frames run as plain JavaScript inside the
1529
- // requestAnimationFrame callback. Messages arriving between frames
1530
- // mark at most one pending frame; the callback renders once with the
1531
- // latest model. The runtime context for OnMount forking and Command
1532
- // forking is captured once here; it is constant for the lifetime of
1533
- // the runtime.
1534
- const runtimeContextForCommands = yield* Effect.context();
1535
- const liveRenderContext = Context.add(Context.add(Context.add(runtimeContextForCommands, Dispatch, dispatch), MountTracker, mountTracker), MountRuntime, mountRuntime);
1536
- // NOTE: the render, Mount drain, DevTools attribution, and
1537
- // patch-time-buffer flush. Shared by the plain path (called directly)
1538
- // and the View Transition path (called from the transition's update
1539
- // callback), which run identical work; only whether they run inside
1540
- // `document.startViewTransition` differs. `isRenderingFrame` gates the
1541
- // buffering of Messages dispatched by patch-time hooks, so it must
1542
- // wrap the actual patch, which on the transition path happens inside
1543
- // the callback, not when the frame is scheduled.
1544
- const runRenderFrameBody = () => {
1545
- isRenderingFrame = true;
1546
- // NOTE: captured before the patch, because `drainPendingMessages`
1547
- // below can advance `liveModel` again before the next frame reads
1548
- // it. What this frame painted is what the next transition animates
1549
- // away from.
1550
- const renderedModel = liveModel;
1551
- const isResuming = currentViewState === 'Paused';
1552
- if (isResuming) {
1553
- // NOTE: Messages accumulated while a historical view owned the DOM
1554
- // did not cause this repaint. A Message arriving during the patch
1555
- // can repopulate this field when the buffered queue drains.
1556
- maybeLastDirtyMessage = Option.none();
1557
- }
1558
- try {
1559
- renderSyncPlain(liveModel, maybeLastDirtyMessage);
1560
- // NOTE: after the patch, so a render that threw leaves this on the
1561
- // Model still on screen, and before `drainPendingMessages` below,
1562
- // whose handlers can advance `liveModel` again.
1563
- lastRenderedModel = renderedModel;
1564
- // NOTE: resume clears the store's pause flag before this frame.
1565
- // Publish Live only after the live DOM has been installed.
1566
- setViewState('Live');
1567
- if (devToolsStore !== null) {
1568
- const mountEvents = drainMountEvents();
1569
- Effect.runFork(devToolsStore.attachRenderedMounts(mountEvents.starts, mountEvents.ends));
1570
- }
1571
- }
1572
- catch (error) {
1573
- Effect.runFork(crashWith(Cause.die(error), maybeLastDirtyMessage));
1574
- }
1575
- finally {
1576
- isRenderingFrame = false;
1577
- }
1578
- // NOTE: Messages dispatched by patch-time hooks (for example,
1579
- // OnUnmount destroys, or Mount emissions) were buffered while the
1580
- // frame held the stack; they process now, after the patch has
1581
- // committed and the frame's Mount events are attributed.
1582
- drainPendingMessages();
1583
- // NOTE: last, so a waiter resumed by the commit observes the same
1584
- // DOM and the same processed-Message ordering it saw when
1585
- // `afterCommit` counted frames.
1586
- settlePendingCommit();
1587
- };
1588
- // NOTE: starts a View Transition around this frame's render when the
1589
- // `viewTransition` predicate matches, returning `true` when it did.
1590
- // `startViewTransition` invokes its update callback asynchronously
1591
- // after snapshotting the old DOM, so the callback reads `liveModel`
1592
- // and `maybeLastDirtyMessage` fresh (the plain loop may have advanced
1593
- // the model while the browser suppressed rendering) and re-checks the
1594
- // disposal and crash guards, which can flip while the transition is
1595
- // pending. The unconfigured path never reaches this function; the
1596
- // `Option.isNone` check in `renderFramePlain` returns first, so a
1597
- // runtime without `viewTransition` allocates no per-frame callback.
1598
- const startFrameViewTransition = (resolved) => {
1599
- if (currentViewState === 'Paused') {
1600
- return false;
1601
- }
1602
- if (resolved.reducedMotionQuery.matches) {
1603
- return false;
1604
- }
1605
- if (Option.isNone(maybeLastDirtyMessage)) {
1606
- return false;
1607
- }
1608
- const maybeDecision = __decideViewTransition(resolved.decide, {
1609
- previousModel: lastRenderedModel,
1610
- model: liveModel,
1611
- message: maybeLastDirtyMessage.value,
1612
- });
1613
- if (Option.isNone(maybeDecision)) {
1614
- return false;
1615
- }
1616
- // NOTE: invalidate an older transition before this one takes
1617
- // ownership of the latest live repaint. The browser may still call
1618
- // the older update callback, but its invalidation guard leaves the
1619
- // DOM and commit notifier to this transition.
1620
- skipPendingViewTransition();
1621
- try {
1622
- const update = {
1623
- isInvalidated: false,
1624
- didRun: false,
1625
- };
1626
- const handle = resolved.startViewTransition(() => {
1627
- if (update.isInvalidated || update.didRun) {
1628
- return;
1629
- }
1630
- update.didRun = true;
1631
- // NOTE: `currentViewState` closes the resume window after the
1632
- // store is live but before its plain frame has restored the live
1633
- // DOM. A transition invalidated by jumpTo stays invalid forever,
1634
- // so its callback cannot repaint inside the stale transition
1635
- // even if it arrives after resume has published `Live`.
1636
- if (isRuntimeDisposed ||
1637
- isCrashed ||
1638
- isPausedNow() ||
1639
- currentViewState === 'Paused') {
1640
- settlePendingCommit();
1641
- return;
1642
- }
1643
- runRenderFrameBody();
1644
- }, maybeDecision.value.maybeTypes);
1645
- maybePendingViewTransition = Option.some({ handle, update });
1646
- __silenceViewTransitionRejections(handle);
1647
- return true;
1648
- }
1649
- catch {
1650
- // NOTE: an escaping throw would leave the rAF callback without a
1651
- // patch and without settling the commit notifier, parking every
1652
- // `Render.afterCommit` on this frame forever.
1653
- return false;
1654
- }
1655
- };
1656
- // NOTE: every path out of a scheduled frame settles the commit
1657
- // notifier, whether or not it patched. A frame abandoned silently
1658
- // would strand any `Render.afterCommit` registered against it, and
1659
- // the Dom helpers that gate on it would never run their DOM work.
1660
- const renderFramePlain = () => {
1661
- isRenderFrameScheduled = false;
1662
- const isRestoringLiveView = isLiveViewRestorePending;
1663
- isLiveViewRestorePending = false;
1664
- // NOTE: a frame scheduled before disposal fires after it; a
1665
- // disposed runtime must not repaint the released container.
1666
- if (isRuntimeDisposed) {
1667
- settlePendingCommit();
1668
- return;
1669
- }
1670
- // NOTE: a frame is running, so the browser got control back; the
1671
- // drain budget starts fresh.
1672
- syncWorkMsSinceYield = 0;
1673
- // NOTE: a Message that dirtied the model can also be the one
1674
- // whose Command crashed the runtime. Without this guard the
1675
- // next animation frame would render the live view over the
1676
- // crash view.
1677
- if (isCrashed) {
1678
- settlePendingCommit();
1679
- return;
1680
- }
1681
- if (isPausedNow()) {
1682
- settlePendingCommit();
1683
- return;
1684
- }
1685
- if (currentViewState === 'Paused' && !isRestoringLiveView) {
1686
- settlePendingCommit();
1687
- return;
1688
- }
1689
- // NOTE: the unconfigured path pays one `Option.isNone` check and
1690
- // renders directly, allocating no per-frame callback. Only a
1691
- // runtime configured with `viewTransition` reaches
1692
- // `startFrameViewTransition`, which decides per frame whether to
1693
- // wrap the render in `document.startViewTransition`. When it does,
1694
- // the render runs later, inside the transition's update callback.
1695
- if (Option.isNone(maybeResolvedViewTransition)) {
1696
- runRenderFrameBody();
1697
- return;
1698
- }
1699
- if (!startFrameViewTransition(maybeResolvedViewTransition.value)) {
1700
- runRenderFrameBody();
1701
- }
1702
- };
1703
- const renderSyncPlain = (model, maybeMessage) => {
1704
- const [nextDocument, maybeViewDuration] = measureSlowPhase(resolvedSlowView, () => {
1705
- beginHtmlRender(boundaryRegistry);
1706
- setHtmlRuntime(dispatch.dispatchSync, liveRenderContext, boundaryRegistry);
1707
- try {
1708
- return view(model, htmlBuilder);
1709
- }
1710
- finally {
1711
- clearHtmlRuntime();
1712
- }
1713
- });
1714
- reportSlowPhase(resolvedSlowView, maybeViewDuration, (durationMs, thresholdMs) => ({
1715
- _tag: 'View',
1716
- model,
1717
- message: maybeMessage,
1718
- durationMs,
1719
- thresholdMs,
1720
- }));
1721
- const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
1722
- const [patchedVNode, maybePatchDuration] = measureSlowPhase(resolvedSlowPatch, () => patchRuntimeVNode(maybeCurrentVNode, nextDocument.body, boundaryRegistry.dedupeSeen));
1723
- vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
1724
- reportSlowPhase(resolvedSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
1725
- _tag: 'Patch',
1726
- model,
1727
- message: maybeMessage,
1728
- durationMs,
1729
- thresholdMs,
1730
- }));
1731
- if (manageDocument) {
1732
- applyDocumentMetadata(nextDocument, patchedVNode.elm);
1733
- }
1734
- if (import.meta.hot) {
1735
- duplicateIdScanner?.schedule(patchedVNode.elm);
1736
- }
1737
- };
1738
- const scheduleRenderFrame = (isRestoringLiveView = false) => {
1739
- if (isRestoringLiveView) {
1740
- isLiveViewRestorePending = true;
1741
- }
1742
- if (isRenderFrameScheduled) {
1743
- return;
1744
- }
1745
- isRenderFrameScheduled = true;
1746
- commitNotifier.markCommitPending();
1747
- requestAnimationFrame(renderFramePlain);
1748
- };
288
+ yield* recordInit(initModel, initCommands);
1749
289
  if (subscriptions) {
1750
- yield* pipe(subscriptions, Record.toEntries, Effect.forEach(([key, { dependenciesSchema, modelToDependencies, keepAliveEquivalence, dependenciesToStream, },]) => Effect.gen(function* () {
1751
- const equivalence = keepAliveEquivalence ??
1752
- Schema.toEquivalence(dependenciesSchema);
1753
- const [initDependencies, maybeInitDependenciesDuration] = measureSlowPhase(resolvedSlowSubscriptionDependencies, () => modelToDependencies(initModel));
1754
- reportSlowPhase(resolvedSlowSubscriptionDependencies, maybeInitDependenciesDuration, (durationMs, thresholdMs) => ({
1755
- _tag: 'SubscriptionDependencies',
1756
- subscriptionKey: key,
1757
- model: initModel,
1758
- durationMs,
1759
- thresholdMs,
1760
- }));
1761
- const latestDependenciesRef = yield* Ref.make(initDependencies);
1762
- const modelChangesStream = Stream.fromPubSub(modelPubSub).pipe(
1763
- // NOTE: Ref.set runs upstream of Stream.changesWith on
1764
- // every model change, so readDependencies() returns
1765
- // current values even when the equivalence filter
1766
- // doesn't emit. Moving this into a tap after
1767
- // changesWith would silently break subscribers whose
1768
- // dependencies are equivalence-stable across model
1769
- // changes.
1770
- Stream.mapEffect(model => Effect.gen(function* () {
1771
- const [dependencies, maybeDependenciesDuration] = measureSlowPhase(resolvedSlowSubscriptionDependencies, () => modelToDependencies(model));
1772
- reportSlowPhase(resolvedSlowSubscriptionDependencies, maybeDependenciesDuration, (durationMs, thresholdMs) => ({
1773
- _tag: 'SubscriptionDependencies',
1774
- subscriptionKey: key,
1775
- model,
1776
- durationMs,
1777
- thresholdMs,
1778
- }));
1779
- yield* Ref.set(latestDependenciesRef, dependencies);
1780
- return dependencies;
1781
- })));
1782
- yield* Effect.forkIn(runtimeScope)(Stream.concat(Stream.make(initDependencies), modelChangesStream).pipe(Stream.changesWith(equivalence), Stream.switchMap(dependencies => dependenciesToStream(dependencies, () => Ref.getUnsafe(latestDependenciesRef))), Stream.runForEach(message =>
1783
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1784
- enqueueMessageEffect(message)), provideAllResources, Effect.catchCause(cause => crashWith(cause, Option.none()))));
1785
- }), {
1786
- concurrency: 'unbounded',
1787
- discard: true,
1788
- }));
1789
- }
1790
- const maybeRequirementsToLifecycle = (config, resourceRef) => (maybeRequirements) => {
1791
- if (Option.isOption(maybeRequirements) &&
1792
- Option.isNone(maybeRequirements)) {
1793
- return Stream.empty;
1794
- }
1795
- const requirements = Option.isOption(maybeRequirements)
1796
- ? Option.getOrThrow(maybeRequirements)
1797
- : maybeRequirements;
1798
- const acquire = Effect.gen(function* () {
1799
- const value = yield* config.acquire(requirements);
1800
- yield* Ref.set(resourceRef, Option.some(value));
1801
- return value;
290
+ yield* forkSubscriptionFibers({
291
+ subscriptions,
292
+ initModel,
293
+ modelPubSub,
294
+ runtimeScope,
295
+ maybeSlowSubscriptionDependencies,
296
+ enqueueMessageEffect,
297
+ provideAllResources,
298
+ crashWith,
1802
299
  });
1803
- const release = (value) => Effect.gen(function* () {
1804
- yield* config
1805
- .release(value)
1806
- .pipe(Effect.catchCause(() => Effect.void));
1807
- yield* Ref.set(resourceRef, Option.none());
1808
- yield* enqueueMessageEffect(config.onReleased());
1809
- });
1810
- return pipe(Stream.scoped(Stream.fromEffect(Effect.acquireRelease(acquire, release))), Stream.flatMap(value => Stream.concat(Stream.make(config.onAcquired(value)), Stream.never)), Stream.map(Effect.succeed), Stream.catch(error => Stream.make(Effect.succeed(config.onAcquireError(error)))));
1811
- };
1812
- const forkManagedResourceLifecycle = ({ config, ref: resourceRef, }) => Effect.gen(function* () {
1813
- const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
1814
- const equivalence = Schema.toEquivalence(config.schema);
1815
- yield* Effect.forkIn(runtimeScope)(modelStream.pipe(Stream.map(config.modelToMaybeRequirements), Stream.changesWith(equivalence), Stream.switchMap(maybeRequirementsToLifecycle(config, resourceRef)), Stream.runForEach(Effect.flatMap(enqueueMessageEffect)),
1816
- // NOTE: mirrors the Subscription fork so a defect in
1817
- // `modelToMaybeRequirements` or the equivalence surfaces as
1818
- // the crash view instead of dying silently in this detached
1819
- // fiber. `provideAllResources` is not needed: `acquire` only
1820
- // requires `Scope`, which `Stream.scoped` supplies, and
1821
- // `release` requires nothing.
1822
- Effect.catchCause(cause => crashWith(cause, Option.none()))));
1823
- });
1824
- yield* Effect.forEach(managedResourceRefs, forkManagedResourceLifecycle, {
1825
- concurrency: 'unbounded',
1826
- discard: true,
300
+ }
301
+ yield* forkManagedResourceFibers({
302
+ managedResourceRefs,
303
+ initModel,
304
+ modelPubSub,
305
+ runtimeScope,
306
+ enqueueMessageEffect,
307
+ crashWith,
1827
308
  });
1828
309
  // NOTE: registered before the boot buffer drains, so an interrupt
1829
310
  // landing anywhere after this yield tears down with the flag set
@@ -1834,7 +315,7 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
1834
315
  // false, so every dispatch buffers and dies with the closure.
1835
316
  // Either way no Message is processed against a closing runtime.
1836
317
  yield* Effect.addFinalizer(() => Effect.sync(() => {
1837
- isRuntimeDisposed = true;
318
+ status.isRuntimeDisposed = true;
1838
319
  // NOTE: a transition outliving the runtime would keep animating
1839
320
  // over a container the teardown is about to restore.
1840
321
  skipPendingViewTransition();
@@ -1852,8 +333,7 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
1852
333
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1853
334
  command, Option.none());
1854
335
  }
1855
- isBootComplete = true;
1856
- drainPendingMessages();
336
+ completeBoot();
1857
337
  // NOTE: suspend forever. Messages are processed synchronously on
1858
338
  // the dispatching stack and render frames run as plain rAF
1859
339
  // callbacks, so this fiber's only remaining job is keeping the
@@ -1876,549 +356,3 @@ const makeRuntime = ({ ports, kind, Model, Flags: FlagsCodec, configuredFlags, i
1876
356
  });
1877
357
  return program;
1878
358
  };
1879
- // NOTE: how long one synchronous drain may hold the stack before the
1880
- // remaining Messages defer to a new task so the browser can paint. Only
1881
- // multi-Message bursts ever reach the check; the single-Message path never
1882
- // reads the clock beyond the drain start.
1883
- const DRAIN_BUDGET_MS = 5;
1884
- const currentLocationUrl = () => {
1885
- const { origin, pathname, search } = window.location;
1886
- return `${origin}${pathname}${search}`;
1887
- };
1888
- const documentMetadataStates = new WeakMap();
1889
- const observeDocumentMetadataMutations = (observer) => {
1890
- const observedHead = document.head;
1891
- observer.observe(observedHead, {
1892
- attributes: true,
1893
- attributeFilter: ['rel', 'href', 'property', 'content'],
1894
- childList: true,
1895
- characterData: true,
1896
- subtree: true,
1897
- });
1898
- observer.observe(document.documentElement, {
1899
- attributes: true,
1900
- attributeFilter: ['lang', 'dir'],
1901
- });
1902
- return observedHead;
1903
- };
1904
- const getOrCreateDocumentMetadataState = () => {
1905
- const existingState = documentMetadataStates.get(document);
1906
- if (existingState !== undefined) {
1907
- return existingState;
1908
- }
1909
- const invalidation = { isInvalidated: true };
1910
- const observer = new MutationObserver(() => {
1911
- invalidation.isInvalidated = true;
1912
- });
1913
- const metadataState = {
1914
- elements: {},
1915
- observer,
1916
- invalidation,
1917
- observedHead: observeDocumentMetadataMutations(observer),
1918
- };
1919
- documentMetadataStates.set(document, metadataState);
1920
- return metadataState;
1921
- };
1922
- const findOrCreateDocumentMetadataElements = (metadataState) => {
1923
- const { elements } = metadataState;
1924
- let canonical = elements.canonical;
1925
- if (canonical === undefined || canonical.parentNode !== document.head) {
1926
- canonical =
1927
- document.head.querySelector('link[rel="canonical"]') ??
1928
- document.head.appendChild(document.createElement('link'));
1929
- elements.canonical = canonical;
1930
- }
1931
- let ogUrl = elements.ogUrl;
1932
- if (ogUrl === undefined || ogUrl.parentNode !== document.head) {
1933
- ogUrl =
1934
- document.head.querySelector('meta[property="og:url"]') ??
1935
- document.head.appendChild(document.createElement('meta'));
1936
- elements.ogUrl = ogUrl;
1937
- }
1938
- return { canonical, ogUrl };
1939
- };
1940
- const readOrCacheCurrentLocationUrl = (metadataState) => {
1941
- const currentLocationHref = window.location.href;
1942
- if (metadataState.cachedLocationHref === currentLocationHref &&
1943
- metadataState.cachedLocationCanonical !== undefined) {
1944
- return metadataState.cachedLocationCanonical;
1945
- }
1946
- const canonicalUrl = currentLocationUrl();
1947
- metadataState.cachedLocationHref = currentLocationHref;
1948
- metadataState.cachedLocationCanonical = canonicalUrl;
1949
- return canonicalUrl;
1950
- };
1951
- const rebindDocumentMetadataObserverToCurrentHead = (metadataState) => {
1952
- // NOTE: a MutationObserver remains attached to a detached head after
1953
- // document.head is replaced. Rebind it and invalidate the metadata snapshot
1954
- // before the next unchanged guard.
1955
- metadataState.observer.disconnect();
1956
- metadataState.observedHead = observeDocumentMetadataMutations(metadataState.observer);
1957
- metadataState.invalidation.isInvalidated = true;
1958
- };
1959
- const reconcileDocumentMetadata = (metadataState, nextMetadata) => {
1960
- if (document.title !== nextMetadata.title) {
1961
- document.title = nextMetadata.title;
1962
- }
1963
- const { documentElement } = document;
1964
- if (nextMetadata.lang !== undefined &&
1965
- documentElement.lang !== nextMetadata.lang) {
1966
- documentElement.lang = nextMetadata.lang;
1967
- }
1968
- if (nextMetadata.dirAttribute !== undefined &&
1969
- documentElement.dir !== nextMetadata.dirAttribute) {
1970
- documentElement.dir = nextMetadata.dirAttribute;
1971
- }
1972
- const metadataElements = findOrCreateDocumentMetadataElements(metadataState);
1973
- if (metadataElements.canonical.getAttribute('rel') !== 'canonical') {
1974
- metadataElements.canonical.setAttribute('rel', 'canonical');
1975
- }
1976
- if (metadataElements.canonical.getAttribute('href') !==
1977
- nextMetadata.canonicalUrl) {
1978
- metadataElements.canonical.setAttribute('href', nextMetadata.canonicalUrl);
1979
- }
1980
- if (metadataElements.ogUrl.getAttribute('property') !== 'og:url') {
1981
- metadataElements.ogUrl.setAttribute('property', 'og:url');
1982
- }
1983
- if (metadataElements.ogUrl.getAttribute('content') !== nextMetadata.ogUrl) {
1984
- metadataElements.ogUrl.setAttribute('content', nextMetadata.ogUrl);
1985
- }
1986
- metadataState.lastApplied = nextMetadata;
1987
- metadataState.invalidation.isInvalidated = false;
1988
- // NOTE: clear the records produced by Foldkit's own writes before the
1989
- // observer callback runs. Otherwise the next unchanged render would be
1990
- // marked dirty and repeat every DOM read this cache is meant to avoid.
1991
- metadataState.observer.takeRecords();
1992
- };
1993
- const applyDocumentMetadata = (nextDocument, mountedRoot) => {
1994
- if (!mountedRoot || !document.body.contains(mountedRoot)) {
1995
- return;
1996
- }
1997
- const metadataState = getOrCreateDocumentMetadataState();
1998
- if (metadataState.observedHead !== document.head) {
1999
- rebindDocumentMetadataObserverToCurrentHead(metadataState);
2000
- }
2001
- const canonicalUrl = nextDocument.canonical ?? readOrCacheCurrentLocationUrl(metadataState);
2002
- const ogUrl = nextDocument.ogUrl ?? canonicalUrl;
2003
- const dirAttribute = nextDocument.dir === undefined
2004
- ? undefined
2005
- : textDirectionToAttribute(nextDocument.dir);
2006
- // NOTE: MutationObserver callbacks are asynchronous. Consume queued records
2007
- // synchronously before trusting the unchanged fast path.
2008
- if (Array.isArrayNonEmpty(metadataState.observer.takeRecords())) {
2009
- metadataState.invalidation.isInvalidated = true;
2010
- }
2011
- const lastApplied = metadataState.lastApplied;
2012
- const isAppliedMetadataCurrent = !metadataState.invalidation.isInvalidated &&
2013
- lastApplied !== undefined &&
2014
- lastApplied.title === nextDocument.title &&
2015
- lastApplied.lang === nextDocument.lang &&
2016
- lastApplied.dirAttribute === dirAttribute &&
2017
- lastApplied.canonicalUrl === canonicalUrl &&
2018
- lastApplied.ogUrl === ogUrl;
2019
- if (isAppliedMetadataCurrent) {
2020
- return;
2021
- }
2022
- reconcileDocumentMetadata(metadataState, {
2023
- title: nextDocument.title,
2024
- lang: nextDocument.lang,
2025
- dirAttribute,
2026
- canonicalUrl,
2027
- ogUrl,
2028
- });
2029
- };
2030
- const renderCrashView = (context, crash, container, vnodeSlot, manageDocument) => {
2031
- console.error('[foldkit] Application crash:', context.error);
2032
- if (crash?.report) {
2033
- try {
2034
- crash.report(context);
2035
- }
2036
- catch (reportError) {
2037
- console.error('[foldkit] crash.report failed:', reportError);
2038
- }
2039
- }
2040
- const crashContext = Context.make(Dispatch, noOpDispatch).pipe(Context.add(MountTracker, {
2041
- started: () => { },
2042
- ended: () => { },
2043
- }));
2044
- try {
2045
- setHtmlRuntime(noOpDispatch.dispatchSync, crashContext);
2046
- let crashDocument;
2047
- try {
2048
- crashDocument = crash?.view
2049
- ? crash.view(context, htmlBuilderFor())
2050
- : defaultCrashView(context);
2051
- }
2052
- finally {
2053
- clearHtmlRuntime();
2054
- }
2055
- const patchedVNode = __patchVNode(vnodeSlot.maybeCurrentVNode, crashDocument.body, container);
2056
- vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
2057
- if (manageDocument) {
2058
- applyDocumentMetadata(crashDocument, patchedVNode.elm);
2059
- }
2060
- }
2061
- catch (viewError) {
2062
- console.error('[foldkit] crash.view failed:', viewError);
2063
- const fallbackViewError = viewError instanceof Error ? viewError : new Error(String(viewError));
2064
- setHtmlRuntime(noOpDispatch.dispatchSync, crashContext);
2065
- let fallbackDocument;
2066
- try {
2067
- fallbackDocument = defaultCrashView(context, fallbackViewError);
2068
- }
2069
- finally {
2070
- clearHtmlRuntime();
2071
- }
2072
- const patchedVNode = __patchVNode(vnodeSlot.maybeCurrentVNode, fallbackDocument.body, container);
2073
- vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
2074
- if (manageDocument) {
2075
- applyDocumentMetadata(fallbackDocument, patchedVNode.elm);
2076
- }
2077
- }
2078
- };
2079
- export function makeApplication(config) {
2080
- const { container } = config;
2081
- const hasRouting = 'routing' in config;
2082
- const hasFlags = 'Flags' in config;
2083
- const hydration = findDocumentHydration(container, hasFlags);
2084
- const resolvedContainer = hydration?.root ?? container;
2085
- if (resolvedContainer === null) {
2086
- // A server-rendered page whose root lost its stamp reaches exactly here:
2087
- // template injection put the render where the placeholder was, so
2088
- // `getElementById` finds nothing and the stamp that would have named the
2089
- // root is gone. There is no handoff to refuse further along, and the markup
2090
- // is as live as any other refused page, so it is contained here.
2091
- if (hasServerRenderedMarkup(document)) {
2092
- containRefusedPage(document);
2093
- }
2094
- throw new Error('[foldkit] Container is null. Make sure the element exists in the DOM ' +
2095
- 'before calling makeApplication (e.g. that your <div id="root"></div> has ' +
2096
- 'rendered, and your script runs after it). On a server-rendered page ' +
2097
- 'the runtime instead finds the root by its `data-foldkit-app` stamp.');
2098
- }
2099
- const currentUrl = hasRouting
2100
- ? Option.getOrThrow(urlFromString(window.location.href))
2101
- : undefined;
2102
- const baseConfig = {
2103
- kind: 'Application',
2104
- Model: config.Model,
2105
- update: config.update,
2106
- view: config.view,
2107
- manageDocument: true,
2108
- ports: config.ports,
2109
- ...(config.subscriptions && { subscriptions: config.subscriptions }),
2110
- container: resolvedContainer,
2111
- ...(hydration && { hydration }),
2112
- ...(hasRouting && { routing: config.routing }),
2113
- ...(config.crash && { crash: config.crash }),
2114
- ...(Predicate.isNotUndefined(config.slow) && {
2115
- slow: config.slow,
2116
- }),
2117
- ...(Predicate.isNotUndefined(config.viewTransition) && {
2118
- viewTransition: config.viewTransition,
2119
- }),
2120
- ...(Predicate.isNotUndefined(config.freezeModel) && {
2121
- freezeModel: config.freezeModel,
2122
- }),
2123
- ...(Predicate.isNotUndefined(config.preserveScroll) && {
2124
- preserveScroll: config.preserveScroll,
2125
- }),
2126
- ...(config.resources && { resources: config.resources }),
2127
- ...(config.managedResources && {
2128
- managedResources: config.managedResources,
2129
- }),
2130
- ...(Predicate.isNotUndefined(config.devTools) && {
2131
- devTools: config.devTools,
2132
- }),
2133
- };
2134
- /* eslint-disable @typescript-eslint/consistent-type-assertions */
2135
- if (hasFlags && hasRouting) {
2136
- return makeRuntime({
2137
- ...baseConfig,
2138
- Flags: config.Flags,
2139
- configuredFlags: Option.none(),
2140
- isFlagsRequired: true,
2141
- init: (flags, url) => config.init(flags, url ?? currentUrl),
2142
- });
2143
- }
2144
- else if (hasRouting) {
2145
- return makeRuntime({
2146
- ...baseConfig,
2147
- Flags: Schema.Void,
2148
- configuredFlags: Option.none(),
2149
- isFlagsRequired: false,
2150
- init: (_flags, url) => config.init(url ?? currentUrl),
2151
- });
2152
- }
2153
- else if (hasFlags) {
2154
- return makeRuntime({
2155
- ...baseConfig,
2156
- Flags: config.Flags,
2157
- configuredFlags: Option.none(),
2158
- isFlagsRequired: true,
2159
- init: (flags) => config.init(flags),
2160
- });
2161
- }
2162
- else {
2163
- return makeRuntime({
2164
- ...baseConfig,
2165
- Flags: Schema.Void,
2166
- configuredFlags: Option.none(),
2167
- isFlagsRequired: false,
2168
- init: () => config.init(),
2169
- });
2170
- }
2171
- /* eslint-enable @typescript-eslint/consistent-type-assertions */
2172
- }
2173
- const toCrashConfig = (crash) => {
2174
- if (Predicate.isUndefined(crash)) {
2175
- return undefined;
2176
- }
2177
- const elementCrashView = crash.view;
2178
- return {
2179
- ...(Predicate.isNotUndefined(elementCrashView) && {
2180
- view: (context, h) => ({
2181
- title: '',
2182
- body: elementCrashView(context, h),
2183
- }),
2184
- }),
2185
- ...(Predicate.isNotUndefined(crash.report) && {
2186
- report: crash.report,
2187
- }),
2188
- };
2189
- };
2190
- export function makeElement(config) {
2191
- const { container } = config;
2192
- if (container === null) {
2193
- throw new Error('[foldkit] Container is null. Make sure the element exists in the DOM ' +
2194
- 'before calling makeElement (e.g. that your <div id="root"></div> has ' +
2195
- 'rendered, and your script runs after it).');
2196
- }
2197
- const hasFlags = 'Flags' in config;
2198
- const elementView = config.view;
2199
- const view = (model, h) => ({
2200
- title: '',
2201
- body: elementView(model, h),
2202
- });
2203
- const crash = toCrashConfig(config.crash);
2204
- const baseConfig = {
2205
- kind: 'Element',
2206
- Model: config.Model,
2207
- update: config.update,
2208
- view,
2209
- manageDocument: false,
2210
- ports: config.ports,
2211
- ...(config.subscriptions && { subscriptions: config.subscriptions }),
2212
- container,
2213
- ...(Predicate.isNotUndefined(crash) && { crash }),
2214
- ...(Predicate.isNotUndefined(config.slow) && {
2215
- slow: config.slow,
2216
- }),
2217
- ...(Predicate.isNotUndefined(config.viewTransition) && {
2218
- viewTransition: config.viewTransition,
2219
- }),
2220
- ...(Predicate.isNotUndefined(config.freezeModel) && {
2221
- freezeModel: config.freezeModel,
2222
- }),
2223
- ...(config.resources && { resources: config.resources }),
2224
- ...(config.managedResources && {
2225
- managedResources: config.managedResources,
2226
- }),
2227
- ...(Predicate.isNotUndefined(config.devTools) && {
2228
- devTools: config.devTools,
2229
- }),
2230
- };
2231
- /* eslint-disable @typescript-eslint/consistent-type-assertions */
2232
- if (hasFlags) {
2233
- return makeRuntime({
2234
- ...baseConfig,
2235
- Flags: config.Flags,
2236
- configuredFlags: Option.some(config.flags),
2237
- isFlagsRequired: true,
2238
- init: (flags) => config.init(flags),
2239
- });
2240
- }
2241
- else {
2242
- return makeRuntime({
2243
- ...baseConfig,
2244
- Flags: Schema.Void,
2245
- configuredFlags: Option.none(),
2246
- isFlagsRequired: false,
2247
- init: () => config.init(),
2248
- });
2249
- }
2250
- /* eslint-enable @typescript-eslint/consistent-type-assertions */
2251
- }
2252
- const encodePreserveModelMessage = Schema.encodeUnknownSync(PreserveModelMessage);
2253
- const encodeRequestModelMessage = Schema.encodeUnknownSync(RequestModelMessage);
2254
- const decodeRestoreModelMessage = Schema.decodeUnknownExit(RestoreModelMessage);
2255
- const preserveModel = (id, encodedModel, isHmrReload) => {
2256
- if (import.meta.hot) {
2257
- import.meta.hot.send('foldkit:preserve-model', encodePreserveModelMessage(PreserveModelMessage.make({ id, model: encodedModel, isHmrReload })));
2258
- }
2259
- };
2260
- const PLUGIN_RESPONSE_TIMEOUT_MS = 500;
2261
- // NOTE: scheduling fix for browser performance. Effect needs to defer work
2262
- // onto a future tick of the event loop. The default browser scheduler picks
2263
- // `setTimeout(f, 0)`, but browsers clamp `setTimeout` to a minimum of 4ms.
2264
- // `queueMicrotask` runs on the very next tick (sub-millisecond). Dispatch no
2265
- // longer routes through the Effect scheduler, but Command and Subscription
2266
- // fibers still do; without this override every fiber yield (for example, an
2267
- // op-budget suspension, or a Stream step) would take an extra 4-16ms
2268
- // round-trip before
2269
- // its result Message lands.
2270
- const microtaskSetImmediate = (callback) => {
2271
- let cancelled = false;
2272
- queueMicrotask(() => {
2273
- if (!cancelled)
2274
- callback();
2275
- });
2276
- return () => {
2277
- cancelled = true;
2278
- };
2279
- };
2280
- const browserScheduler = new Scheduler.MixedScheduler('async', microtaskSetImmediate);
2281
- const provideBrowserScheduler = (effect) => Effect.provide(effect, Layer.succeed(Scheduler.Scheduler, browserScheduler));
2282
- // NOTE: asks @foldkit/vite-plugin for a model preserved across the last HMR
2283
- // reload. The plugin only serves a model whose preservation was flushed by a
2284
- // reload, so a host-driven dispose-then-embed remount initializes fresh while
2285
- // a code reload restores state.
2286
- const resolveHmrModel = (runtimeId) => {
2287
- const hot = import.meta.hot;
2288
- if (!hot) {
2289
- return Effect.succeed(undefined);
2290
- }
2291
- return pipe(Effect.callback(resume => {
2292
- const handler = (message) => {
2293
- Exit.match(decodeRestoreModelMessage(message), {
2294
- onFailure: Function.constVoid,
2295
- onSuccess: ({ id, model }) => {
2296
- if (id === runtimeId) {
2297
- hot.off('foldkit:restore-model', handler);
2298
- resume(Effect.succeed(model));
2299
- }
2300
- },
2301
- });
2302
- };
2303
- hot.on('foldkit:restore-model', handler);
2304
- hot.send('foldkit:request-model', encodeRequestModelMessage(RequestModelMessage.make({ id: runtimeId })));
2305
- return Effect.sync(() => hot.off('foldkit:restore-model', handler));
2306
- }), Effect.timeout(PLUGIN_RESPONSE_TIMEOUT_MS), Effect.catchTag('TimeoutError', () => {
2307
- console.warn('[foldkit] No response from @foldkit/vite-plugin. Add it to your vite.config.ts for HMR model preservation:\n\n' +
2308
- " import { foldkit } from '@foldkit/vite-plugin'\n\n" +
2309
- ' export default defineConfig({ plugins: [foldkit()] })\n\n' +
2310
- 'Starting without HMR support.');
2311
- return Effect.succeed(undefined);
2312
- }));
2313
- };
2314
- /** Starts a program Effect with explicit boot inputs for runtime tests.
2315
- * @internal */
2316
- export const __startProgram = (program, hmrModel, bootMode, flags, buildId) => {
2317
- const internals = runtimeInternals.get(program);
2318
- if (Predicate.isUndefined(internals)) {
2319
- return Effect.die(new Error('[foldkit] Runtime boot expects a program created by ' +
2320
- 'makeApplication or makeElement.'));
2321
- }
2322
- if (bootMode === 'Hydrate' && internals.kind !== 'Application') {
2323
- return Effect.die(new Error('[foldkit] Runtime.hydrate expects a program created by ' +
2324
- 'makeApplication.'));
2325
- }
2326
- return internals.startWith(Option.none(), hmrModel, bootMode, flags, buildId);
2327
- };
2328
- // NOTE: deliberately not `BrowserRuntime.runMain`, which interrupts the
2329
- // runtime on `beforeunload`. `beforeunload` is a question, not a commitment:
2330
- // the browser also fires it for a click on a download link, for a navigation
2331
- // the user cancels, and when freezing the page into the back/forward cache.
2332
- // The document survives all three, but the interrupt finalizer has already
2333
- // put the container element back empty, so the page is left alive with no app
2334
- // in it. A page-owning runtime gains nothing from tearing itself down while
2335
- // the document is on its way out, so it starts with no page-lifecycle
2336
- // interrupt at all and lets the document take the runtime with it. Error
2337
- // reporting and the keep-alive interval come from `makeRunMain` either way.
2338
- const runMainWithoutUnloadInterrupt = Runtime.makeRunMain(Function.constVoid);
2339
- const startProgram = (program, bootMode, flags, buildId) => {
2340
- runMainWithoutUnloadInterrupt(provideBrowserScheduler(Effect.flatMap(resolveHmrModel(program.runtimeId), hmrModel => __startProgram(program, hmrModel, bootMode, flags, buildId))));
2341
- };
2342
- export function run(program, options) {
2343
- startProgram(program, 'Fresh', options?.flags);
2344
- }
2345
- /** Starts a Foldkit runtime by adopting a server-rendered DOM in place instead
2346
- * of building it fresh. Use this as the client entry for a page served by
2347
- * `renderToString`: the first render attaches to the stamped root, keeps the
2348
- * existing nodes, and reconstructs the Model from the Flags the server
2349
- * embedded. The handoff is strict: a missing server root, an empty or
2350
- * duplicated root stamp, more than one stamped root, a requested root outside
2351
- * the document body light DOM, a missing Flags payload, an undecodable
2352
- * payload, or a page from another deployment terminates startup. Every one of
2353
- * those contains the page first:
2354
- * the document's body is marked `inert` and a nondismissable modal shield is
2355
- * opened above existing top-layer content, so pointer and physical keyboard
2356
- * input do not activate same-document native links, forms, or controls.
2357
- * Containment leaves author-owned dialogs open without calling `close` or
2358
- * dispatching `cancel`.
2359
- * Nothing is moved, so no custom element reconnects and no frame reloads.
2360
- * This is not a script, event, or embedded-document sandbox: existing
2361
- * capture-phase handlers, browser-generated top-layer events, timers, and
2362
- * stale scripts can still run. Controls in embedded documents can still
2363
- * receive input if stale code focuses them. Stale code can also open newer
2364
- * top-layer UI. Use `run` in a separate client-only entry when the page should
2365
- * boot without server output.
2366
- *
2367
- * @experimental Server rendering and hydration are experimental while their
2368
- * contracts settle. */
2369
- export const hydrate = (program, options) => {
2370
- startProgram(program, 'Hydrate', undefined, options?.buildId);
2371
- };
2372
- const buildPortHandles = (ports, connector) => {
2373
- const handles = {};
2374
- if (Predicate.isNotUndefined(ports)) {
2375
- Object.entries(ports.inbound ?? {}).forEach(([portName, port]) => {
2376
- handles[portName] = {
2377
- send: (value) => connector.sendInbound(portName, port, value),
2378
- };
2379
- });
2380
- Object.entries(ports.outbound ?? {}).forEach(([portName, port]) => {
2381
- handles[portName] = {
2382
- subscribe: (listener) => connector.addListener(port, listener),
2383
- };
2384
- });
2385
- }
2386
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
2387
- return handles;
2388
- };
2389
- export function embed(program, options) {
2390
- const internals = runtimeInternals.get(program);
2391
- if (Predicate.isUndefined(internals)) {
2392
- throw new Error('[foldkit] embed expects a program created by makeApplication or makeElement.');
2393
- }
2394
- if (internals.isEmbedActive) {
2395
- throw new Error('[foldkit] This program is already embedded. Dispose the existing ' +
2396
- 'handle first, or create a separate program: each program owns one ' +
2397
- 'container.');
2398
- }
2399
- internals.isEmbedActive = true;
2400
- const connector = makeHostConnector();
2401
- // NOTE: a dispose immediately followed by a fresh embed (React strict mode
2402
- // runs effects exactly that way) must not start the new runtime while the
2403
- // old one is still tearing down: the teardown finalizer is what puts the
2404
- // container element back in the DOM. Awaiting the previous fiber's exit
2405
- // sequences the two.
2406
- const startEffect = pipe(Option.match(internals.maybeActiveFiber, {
2407
- onNone: () => Effect.void,
2408
- onSome: previousFiber => Effect.asVoid(Fiber.await(previousFiber)),
2409
- }), Effect.andThen(resolveHmrModel(program.runtimeId)), Effect.flatMap(hmrModel => internals.startWith(Option.some(connector), hmrModel, 'Fresh', options?.flags)));
2410
- const fiber = Effect.runFork(provideBrowserScheduler(startEffect));
2411
- internals.maybeActiveFiber = Option.some(fiber);
2412
- let isHandleDisposed = false;
2413
- const dispose = () => {
2414
- if (isHandleDisposed) {
2415
- return;
2416
- }
2417
- isHandleDisposed = true;
2418
- connector.dispose();
2419
- internals.isEmbedActive = false;
2420
- Effect.runFork(Fiber.interrupt(fiber));
2421
- };
2422
- const ports = buildPortHandles(program.ports, connector);
2423
- return { ports, dispose };
2424
- }