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.
- package/dist/devTools/host.d.ts +2 -2
- package/dist/devTools/host.d.ts.map +1 -1
- package/dist/devTools/host.js +1 -1
- package/dist/experimental/machine/machine.d.ts +278 -84
- package/dist/experimental/machine/machine.d.ts.map +1 -1
- package/dist/experimental/machine/machine.js +362 -212
- package/dist/runtime/browserListeners.d.ts +7 -1
- package/dist/runtime/browserListeners.d.ts.map +1 -1
- package/dist/runtime/browserScheduler.d.ts +3 -0
- package/dist/runtime/browserScheduler.d.ts.map +1 -0
- package/dist/runtime/browserScheduler.js +22 -0
- package/dist/runtime/crashUI.d.ts +36 -5
- package/dist/runtime/crashUI.d.ts.map +1 -1
- package/dist/runtime/crashUI.js +55 -2
- package/dist/runtime/devToolsConfig.d.ts +85 -0
- package/dist/runtime/devToolsConfig.d.ts.map +1 -0
- package/dist/runtime/devToolsConfig.js +49 -0
- package/dist/runtime/devToolsIntegration.d.ts +56 -0
- package/dist/runtime/devToolsIntegration.d.ts.map +1 -0
- package/dist/runtime/devToolsIntegration.js +186 -0
- package/dist/runtime/dispatch.d.ts +10 -0
- package/dist/runtime/dispatch.d.ts.map +1 -0
- package/dist/runtime/dispatch.js +4 -0
- package/dist/runtime/documentMetadata.d.ts +3 -0
- package/dist/runtime/documentMetadata.d.ts.map +1 -0
- package/dist/runtime/documentMetadata.js +148 -0
- package/dist/runtime/duplicateIdScanner.d.ts +16 -0
- package/dist/runtime/duplicateIdScanner.d.ts.map +1 -0
- package/dist/runtime/duplicateIdScanner.js +70 -0
- package/dist/runtime/hmrModelBridge.d.ts +4 -0
- package/dist/runtime/hmrModelBridge.d.ts.map +1 -0
- package/dist/runtime/hmrModelBridge.js +43 -0
- package/dist/runtime/hostConnector.d.ts +61 -0
- package/dist/runtime/hostConnector.d.ts.map +1 -0
- package/dist/runtime/hostConnector.js +141 -0
- package/dist/runtime/hydrationHandoff.d.ts +44 -0
- package/dist/runtime/hydrationHandoff.d.ts.map +1 -0
- package/dist/runtime/hydrationHandoff.js +395 -0
- package/dist/runtime/index.d.ts +16 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +5 -1
- package/dist/runtime/makeApplication.d.ts +71 -0
- package/dist/runtime/makeApplication.d.ts.map +1 -0
- package/dist/runtime/makeApplication.js +98 -0
- package/dist/runtime/makeElement.d.ts +69 -0
- package/dist/runtime/makeElement.d.ts.map +1 -0
- package/dist/runtime/makeElement.js +81 -0
- package/dist/runtime/managedResourceFibers.d.ts +18 -0
- package/dist/runtime/managedResourceFibers.d.ts.map +1 -0
- package/dist/runtime/managedResourceFibers.js +47 -0
- package/dist/runtime/messageQueue.d.ts +27 -0
- package/dist/runtime/messageQueue.d.ts.map +1 -0
- package/dist/runtime/messageQueue.js +133 -0
- package/dist/runtime/public.d.ts +2 -3
- package/dist/runtime/public.d.ts.map +1 -1
- package/dist/runtime/public.js +1 -1
- package/dist/runtime/renderer.d.ts +69 -0
- package/dist/runtime/renderer.d.ts.map +1 -0
- package/dist/runtime/renderer.js +512 -0
- package/dist/runtime/resourceProvider.d.ts +33 -0
- package/dist/runtime/resourceProvider.d.ts.map +1 -0
- package/dist/runtime/resourceProvider.js +98 -0
- package/dist/runtime/runtime.d.ts +133 -412
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +118 -2184
- package/dist/runtime/runtimeStatus.d.ts +15 -0
- package/dist/runtime/runtimeStatus.d.ts.map +1 -0
- package/dist/runtime/runtimeStatus.js +6 -0
- package/dist/runtime/slowPhase.d.ts +82 -0
- package/dist/runtime/slowPhase.d.ts.map +1 -0
- package/dist/runtime/slowPhase.js +83 -0
- package/dist/runtime/start.d.ts +96 -0
- package/dist/runtime/start.d.ts.map +1 -0
- package/dist/runtime/start.js +99 -0
- package/dist/runtime/subscriptionFibers.d.ts +21 -0
- package/dist/runtime/subscriptionFibers.d.ts.map +1 -0
- package/dist/runtime/subscriptionFibers.js +47 -0
- package/dist/runtime/visibility.d.ts +8 -0
- package/dist/runtime/visibility.d.ts.map +1 -0
- package/dist/runtime/visibility.js +6 -0
- package/dist/test/scene.js +1 -1
- package/package.json +1 -1
package/dist/runtime/runtime.js
CHANGED
|
@@ -1,587 +1,34 @@
|
|
|
1
|
-
import { Array,
|
|
2
|
-
import {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
const
|
|
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
|
|
581
|
-
const
|
|
582
|
-
const
|
|
583
|
-
const
|
|
584
|
-
const
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
|
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
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
//
|
|
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
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
//
|
|
1157
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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*
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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
|
-
|
|
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
|
-
}
|