foldkit 0.146.0 → 0.148.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +5 -2
  2. package/dist/buildToken.d.ts +3 -0
  3. package/dist/buildToken.d.ts.map +1 -0
  4. package/dist/buildToken.js +21 -0
  5. package/dist/controlledDomState.d.ts +25 -0
  6. package/dist/controlledDomState.d.ts.map +1 -0
  7. package/dist/controlledDomState.js +240 -0
  8. package/dist/cssStyleProperties.d.ts +6 -0
  9. package/dist/cssStyleProperties.d.ts.map +1 -0
  10. package/dist/cssStyleProperties.js +91 -0
  11. package/dist/customElement/index.d.ts.map +1 -1
  12. package/dist/customElement/index.js +23 -0
  13. package/dist/domReflection.d.ts +73 -0
  14. package/dist/domReflection.d.ts.map +1 -0
  15. package/dist/domReflection.js +557 -0
  16. package/dist/experimental/index.d.ts +1 -0
  17. package/dist/experimental/index.d.ts.map +1 -1
  18. package/dist/experimental/index.js +1 -0
  19. package/dist/experimental/machine/machine.d.ts +22 -3
  20. package/dist/experimental/machine/machine.d.ts.map +1 -1
  21. package/dist/experimental/machine/machine.js +8 -0
  22. package/dist/experimental/server/entry.d.ts +73 -0
  23. package/dist/experimental/server/entry.d.ts.map +1 -0
  24. package/dist/experimental/server/entry.js +41 -0
  25. package/dist/experimental/server/host.d.ts +136 -0
  26. package/dist/experimental/server/host.d.ts.map +1 -0
  27. package/dist/experimental/server/host.js +359 -0
  28. package/dist/experimental/server/index.d.ts +5 -0
  29. package/dist/experimental/server/index.d.ts.map +1 -0
  30. package/dist/experimental/server/index.js +4 -0
  31. package/dist/experimental/server/public.d.ts +3 -0
  32. package/dist/experimental/server/public.d.ts.map +1 -0
  33. package/dist/experimental/server/public.js +1 -0
  34. package/dist/experimental/server/serialize.d.ts +43 -0
  35. package/dist/experimental/server/serialize.d.ts.map +1 -0
  36. package/dist/experimental/server/serialize.js +786 -0
  37. package/dist/experimental/server/server.d.ts +242 -0
  38. package/dist/experimental/server/server.d.ts.map +1 -0
  39. package/dist/experimental/server/server.js +946 -0
  40. package/dist/experimental/server/template.d.ts +53 -0
  41. package/dist/experimental/server/template.d.ts.map +1 -0
  42. package/dist/experimental/server/template.js +617 -0
  43. package/dist/html/index.d.ts +5 -0
  44. package/dist/html/index.d.ts.map +1 -1
  45. package/dist/html/index.js +474 -33
  46. package/dist/hydrate.d.ts +4 -0
  47. package/dist/hydrate.d.ts.map +1 -0
  48. package/dist/hydrate.js +850 -0
  49. package/dist/hydrationMarker.d.ts +10 -0
  50. package/dist/hydrationMarker.d.ts.map +1 -0
  51. package/dist/hydrationMarker.js +9 -0
  52. package/dist/hydrationMarkers.d.ts +15 -0
  53. package/dist/hydrationMarkers.d.ts.map +1 -0
  54. package/dist/hydrationMarkers.js +70 -0
  55. package/dist/nativeInnerHtml.d.ts +13 -0
  56. package/dist/nativeInnerHtml.d.ts.map +1 -0
  57. package/dist/nativeInnerHtml.js +30 -0
  58. package/dist/propertyProvenance.d.ts +33 -0
  59. package/dist/propertyProvenance.d.ts.map +1 -0
  60. package/dist/propertyProvenance.js +78 -0
  61. package/dist/propsModule.d.ts.map +1 -1
  62. package/dist/propsModule.js +149 -15
  63. package/dist/runtime/public.d.ts +2 -2
  64. package/dist/runtime/public.d.ts.map +1 -1
  65. package/dist/runtime/public.js +1 -1
  66. package/dist/runtime/runtime.d.ts +99 -36
  67. package/dist/runtime/runtime.d.ts.map +1 -1
  68. package/dist/runtime/runtime.js +412 -71
  69. package/dist/snabbdom/attributes.d.ts.map +1 -1
  70. package/dist/snabbdom/attributes.js +65 -37
  71. package/dist/snabbdom/h.d.ts +1 -0
  72. package/dist/snabbdom/h.d.ts.map +1 -1
  73. package/dist/snabbdom/h.js +85 -4
  74. package/dist/snabbdom/style.d.ts.map +1 -1
  75. package/dist/snabbdom/style.js +53 -34
  76. package/dist/snabbdom/tovnode.d.ts.map +1 -1
  77. package/dist/snabbdom/tovnode.js +5 -1
  78. package/dist/tagName.d.ts +6 -0
  79. package/dist/tagName.d.ts.map +1 -0
  80. package/dist/tagName.js +11 -0
  81. package/dist/test/apps/attributes.d.ts +1 -0
  82. package/dist/test/apps/attributes.d.ts.map +1 -1
  83. package/dist/test/apps/attributes.js +8 -1
  84. package/dist/test/apps/login.js +1 -1
  85. package/dist/test/matchers.d.ts.map +1 -1
  86. package/dist/test/matchers.js +2 -1
  87. package/dist/test/scene.d.ts.map +1 -1
  88. package/dist/test/scene.js +2 -1
  89. package/dist/vdom.d.ts.map +1 -1
  90. package/dist/vdom.js +25 -1
  91. package/package.json +9 -2
@@ -1,8 +1,11 @@
1
1
  import { Array, Cause, Context, Duration, Effect, Exit, Fiber, Function, Layer, Match, Option, Predicate, PubSub, Record, Ref, Runtime, Scheduler, Schema, Stream, SubscriptionRef, pipe, } from 'effect';
2
+ import { HYDRATION_BUILD_ATTRIBUTE } from '../buildToken.js';
2
3
  import { __CurrentRegistry as __CurrentInterruptRegistry, __makeRegistry as __makeInterruptRegistry, } from '../command/interruptible/index.js';
3
4
  import { createDevToolsStore, } from '../devTools/store.js';
4
5
  import { startWebSocketBridge } from '../devTools/webSocketBridge.js';
5
- import { __beginRender as beginHtmlRender, __beginReplayRender as beginReplayHtmlRender, __clearRuntime as clearHtmlRuntime, __createBoundaryRegistry as createHtmlBoundaryRegistry, __endReplayRender as endReplayHtmlRender, __htmlBuilder as htmlBuilderFor, __setRuntime as setHtmlRuntime, } from '../html/index.js';
6
+ import { __beginRender as beginHtmlRender, __beginReplayRender as beginReplayHtmlRender, __clearRuntime as clearHtmlRuntime, __createBoundaryRegistry as createHtmlBoundaryRegistry, __endReplayRender as endReplayHtmlRender, __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';
6
9
  import { MountTracker } from '../mount/index.js';
7
10
  import { __CurrentPortChannels, __makeInboundChannel, } from '../port/index.js';
8
11
  import { RenderCommit, createCommitNotifier } from '../render/commit.js';
@@ -191,6 +194,205 @@ const createDuplicateIdScanner = () => {
191
194
  /** Effect service tag that provides message dispatching to the view layer. */
192
195
  export class Dispatch extends Context.Service()('@foldkit/Dispatch') {
193
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, so a sibling widget (a stamped root the container is not inside) or
210
+ // an outer application's root (when a nested page carries more than one stamped
211
+ // root) is never wrongly adopted; those fall through to a fresh boot or a hard
212
+ // failure instead. A null container is the replace-parity case, where the server
213
+ // root took the placeholder's place and `getElementById` no longer finds it, so
214
+ // the stamp is the only handle; more than one stamped root is then ambiguous and
215
+ // a hard error rather than a silent wrong-DOM adoption.
216
+ // A runtime id names one application for the whole page: it pairs a root with
217
+ // its Flags payload, and it keys the Model and scroll position hot reloading
218
+ // preserves. Two roots sharing one are not two applications but one claimed
219
+ // twice, so whichever boots second would read the other's handoff and restore
220
+ // the other's Model. `injectIntoTemplate` refuses to build such a page; this is
221
+ // the check for a page assembled some other way.
222
+ //
223
+ // Distinct ids do not make two hydrated applications independent, which is not
224
+ // a supported arrangement: each page-owning application rewrites the document's
225
+ // metadata and installs document-wide navigation listeners.
226
+ const assertRuntimeIdsAreUnique = (stampedRoots) => {
227
+ const seen = new Set();
228
+ for (const root of stampedRoots) {
229
+ const runtimeId = root.getAttribute(FOLDKIT_APP_ATTRIBUTE) ?? '';
230
+ if (seen.has(runtimeId)) {
231
+ containRefusedPage(root.ownerDocument);
232
+ throw new Error(`[foldkit] Found more than one server-rendered root stamped ` +
233
+ `"${runtimeId}". A runtime id names one application for the whole ` +
234
+ 'page: it pairs a root with its Flags payload and keys the Model and ' +
235
+ 'scroll position hot reloading preserves, so two roots sharing one ' +
236
+ "would take each other's state. Render each application with its " +
237
+ 'own `runtimeId`.');
238
+ }
239
+ seen.add(runtimeId);
240
+ }
241
+ };
242
+ // The reason this page cannot be adopted by this client, or `undefined` when
243
+ // the two name the same deployment.
244
+ //
245
+ // The client's id is required and must be non-empty. An absent one would
246
+ // otherwise equal the absent marker on a page served before build ids existed,
247
+ // which reads a page from an unknown deployment as one of this build's own: the
248
+ // exact case the id exists to refuse.
249
+ const buildSkew = (root, buildId, runtimeId) => {
250
+ if (buildId === undefined || buildId === '') {
251
+ return new Error('[foldkit] Runtime.hydrate was given no build id. Hydration compares ' +
252
+ 'the id the server stamped on the root with this client’s own before ' +
253
+ 'it adopts any DOM, and without one a page from any deployment would ' +
254
+ 'be adopted as this one. Pass ' +
255
+ '`buildId: import.meta.env.FOLDKIT_BUILD_ID`, the same value the ' +
256
+ 'server entry passes to `renderToString`.');
257
+ }
258
+ const servedBuild = root.getAttribute(HYDRATION_BUILD_ATTRIBUTE);
259
+ if (servedBuild === buildId) {
260
+ return undefined;
261
+ }
262
+ return new Error(`[foldkit] Runtime.hydrate found application "${runtimeId}" served by ` +
263
+ `${servedBuild === null ? 'no known deployment' : `deployment "${servedBuild}"`}` +
264
+ `, but this client belongs to deployment "${buildId}". Startup stops ` +
265
+ 'here rather than reading a handoff written by other code: the Flags in ' +
266
+ 'the page are that deployment’s, and this build could accept them while ' +
267
+ 'every value in them means something else. Serve the page from the ' +
268
+ 'running deployment, and keep stale HTML out of shared caches.');
269
+ };
270
+ const REFUSED_ATTRIBUTE = 'data-foldkit-refused';
271
+ const REFUSAL_SHIELD_ATTRIBUTE = 'data-foldkit-refusal-shield';
272
+ const refusalShields = new WeakMap();
273
+ const REFUSAL_SHIELD_INPUT_EVENTS = [
274
+ 'auxclick',
275
+ 'click',
276
+ 'contextmenu',
277
+ 'dblclick',
278
+ 'keydown',
279
+ 'keypress',
280
+ 'keyup',
281
+ 'mousedown',
282
+ 'mouseup',
283
+ 'pointerdown',
284
+ 'pointerup',
285
+ 'touchend',
286
+ 'touchstart',
287
+ ];
288
+ const preventRefusalShieldInteraction = (event) => {
289
+ event.preventDefault();
290
+ event.stopImmediatePropagation();
291
+ };
292
+ const openRefusalShield = (shield) => {
293
+ shield.inert = true;
294
+ shield.setAttribute('inert', '');
295
+ if (shield.open && typeof shield.close === 'function') {
296
+ shield.close();
297
+ }
298
+ if (typeof shield.showModal === 'function') {
299
+ shield.showModal();
300
+ }
301
+ else {
302
+ shield.setAttribute('open', '');
303
+ }
304
+ shield.inert = false;
305
+ shield.removeAttribute('inert');
306
+ shield.focus({ preventScroll: true });
307
+ };
308
+ const installRefusalShield = (ownerDocument) => {
309
+ const existing = refusalShields.get(ownerDocument);
310
+ if (existing !== undefined && existing.isConnected) {
311
+ openRefusalShield(existing);
312
+ return;
313
+ }
314
+ const shield = ownerDocument.createElement('dialog');
315
+ shield.setAttribute(REFUSAL_SHIELD_ATTRIBUTE, '');
316
+ shield.setAttribute('aria-label', 'Page unavailable');
317
+ shield.setAttribute('aria-modal', 'true');
318
+ shield.setAttribute('closedby', 'none');
319
+ shield.tabIndex = -1;
320
+ shield.textContent =
321
+ 'This page could not start safely. Reload to get the current version.';
322
+ shield.style.alignItems = 'center';
323
+ shield.style.background = 'rgba(15, 23, 42, 0.96)';
324
+ shield.style.border = '0';
325
+ shield.style.boxSizing = 'border-box';
326
+ shield.style.color = 'white';
327
+ shield.style.font = '600 1rem/1.5 system-ui, sans-serif';
328
+ shield.style.display = 'grid';
329
+ shield.style.height = '100vh';
330
+ shield.style.inset = '0';
331
+ shield.style.margin = '0';
332
+ shield.style.maxHeight = 'none';
333
+ shield.style.maxWidth = 'none';
334
+ shield.style.overflow = 'hidden';
335
+ shield.style.padding = '2rem';
336
+ shield.style.position = 'fixed';
337
+ shield.style.touchAction = 'none';
338
+ shield.style.userSelect = 'none';
339
+ shield.style.width = '100vw';
340
+ shield.addEventListener('cancel', preventRefusalShieldInteraction);
341
+ for (const eventName of REFUSAL_SHIELD_INPUT_EVENTS) {
342
+ shield.addEventListener(eventName, preventRefusalShieldInteraction, {
343
+ capture: true,
344
+ passive: false,
345
+ });
346
+ }
347
+ ownerDocument.documentElement.appendChild(shield);
348
+ refusalShields.set(ownerDocument, shield);
349
+ openRefusalShield(shield);
350
+ };
351
+ const containRefusedPage = (ownerDocument) => {
352
+ const boundary = ownerDocument.body ?? ownerDocument.documentElement;
353
+ boundary.inert = true;
354
+ boundary.setAttribute('inert', '');
355
+ boundary.setAttribute('aria-hidden', 'true');
356
+ boundary.setAttribute(REFUSED_ATTRIBUTE, '');
357
+ installRefusalShield(ownerDocument);
358
+ };
359
+ // Whether this page carries anything a Foldkit server render leaves behind. A
360
+ // resolution failure on such a page is a refused handoff, and the markup is
361
+ // contained; the same failure on a page with none of these markers is a client
362
+ // application whose container never existed, where there is no server render to
363
+ // refuse and nothing to take out of reach.
364
+ const hasServerRenderedMarkup = (ownerDocument) => ownerDocument.querySelector(`[${FOLDKIT_APP_ATTRIBUTE}], [${HYDRATION_BUILD_ATTRIBUTE}], ` +
365
+ `[${FOLDKIT_FLAGS_ATTRIBUTE}]`) !== null;
366
+ const findDocumentHydration = (container, isFlagsRequired) => {
367
+ const stampedRoots = Array.fromIterable(document.querySelectorAll(`[${FOLDKIT_APP_ATTRIBUTE}]`));
368
+ assertRuntimeIdsAreUnique(stampedRoots);
369
+ if (container !== null) {
370
+ if (container.hasAttribute(FOLDKIT_APP_ATTRIBUTE)) {
371
+ return hydrationForRoot(container, isFlagsRequired);
372
+ }
373
+ return Array.match(stampedRoots, {
374
+ onEmpty: () => undefined,
375
+ onNonEmpty: roots => {
376
+ const onlyRoot = Array.headNonEmpty(roots);
377
+ return roots.length === 1 &&
378
+ container.closest(`[${FOLDKIT_APP_ATTRIBUTE}]`) === onlyRoot
379
+ ? hydrationForRoot(onlyRoot, isFlagsRequired)
380
+ : undefined;
381
+ },
382
+ });
383
+ }
384
+ if (stampedRoots.length > 1) {
385
+ containRefusedPage(document);
386
+ throw new Error('[foldkit] Found multiple server-rendered roots stamped with ' +
387
+ `\`${FOLDKIT_APP_ATTRIBUTE}\` but no container to disambiguate them. ` +
388
+ 'Give each app its own container element so the runtime can tell ' +
389
+ 'which root to hydrate.');
390
+ }
391
+ return Option.match(Array.head(stampedRoots), {
392
+ onNone: () => undefined,
393
+ onSome: root => hydrationForRoot(root, isFlagsRequired),
394
+ });
395
+ };
194
396
  const makeHostConnector = () => {
195
397
  let isDisposed = false;
196
398
  let maybeDeliverInbound = Option.none();
@@ -314,7 +516,7 @@ const validatePorts = (ports) => {
314
516
  });
315
517
  };
316
518
  const runtimeInternals = new WeakMap();
317
- const makeRuntime = ({ ports, Model, flags: maybeResolveFlags, init, update, view, manageDocument, subscriptions, container, routing: routingConfig, crash, slow, viewTransition, freezeModel, preserveScroll, resources, managedResources, devTools, }) => {
519
+ 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, }) => {
318
520
  const isSlowVisible = (show) => Match.value(show).pipe(Match.when('Always', () => true), Match.when('Development', () => !!import.meta.hot), Match.exhaustive);
319
521
  const htmlBuilder = htmlBuilderFor();
320
522
  const resolvedSlow = __resolveSlowConfig(slow, isSlowVisible);
@@ -352,8 +554,10 @@ const makeRuntime = ({ ports, Model, flags: maybeResolveFlags, init, update, vie
352
554
  if (Predicate.isNotUndefined(ports)) {
353
555
  validatePorts(ports);
354
556
  }
355
- const runtimeId = container?.id ?? '';
356
- const startWith = (maybeConnector, hmrModel) => {
557
+ const runtimeId = hydration !== undefined && hydration.runtimeId !== ''
558
+ ? hydration.runtimeId
559
+ : (container?.id ?? '');
560
+ const startWith = (maybeConnector, hmrModel, bootMode = 'Fresh', bootFlags, buildId) => {
357
561
  // NOTE: one notifier per runtime, provided across the whole runtime
358
562
  // Effect so Commands, Subscriptions, and Mount-forked Effects all resolve
359
563
  // the same signal. A commit in one embedded application must never wake a
@@ -362,7 +566,8 @@ const makeRuntime = ({ ports, Model, flags: maybeResolveFlags, init, update, vie
362
566
  return Effect.scoped(Effect.gen(function* () {
363
567
  if (runtimeId === '') {
364
568
  return yield* Effect.die(new Error('[foldkit] Runtime container must have an `id` for HMR model preservation. ' +
365
- 'Set `container.id = "app"` (or any unique string) before passing it to makeApplication or makeElement.'));
569
+ 'Set `container.id = "app"` (or any unique string) before passing it to makeApplication or makeElement. ' +
570
+ 'On a server-rendered page the id comes from the `data-foldkit-app` root stamp instead.'));
366
571
  }
367
572
  // NOTE: every perpetual fiber (for example, Subscription streams
368
573
  // and ManagedResource lifecycles) and every Command fiber forks
@@ -434,28 +639,28 @@ const makeRuntime = ({ ports, Model, flags: maybeResolveFlags, init, update, vie
434
639
  });
435
640
  return Effect.provideService(withPortChannels, __CurrentInterruptRegistry, interruptRegistry);
436
641
  };
437
- // NOTE: flags run through the same cached build that Commands and
642
+ // NOTE: Flags run through the same cached build that Commands and
438
643
  // Subscriptions use, rather than being handed the Layer again, so a
439
644
  // service needed both at startup and by a Command is constructed
440
- // once. An app without flags never reaches it, which keeps the Layer
645
+ // once. An app without Flags never reaches it, which keeps the Layer
441
646
  // lazy when the first thing that needs it is a Command.
442
647
  //
443
648
  // NOTE: a Layer that fails to build is not fatal here. Flags resolve
444
649
  // before `init`, so there is no Model for a crash view to render
445
650
  // against and a failure escaping this point kills the app with a
446
- // blank container. Running flags against an empty context instead
447
- // lets an app whose flags never touch the Layer boot as it did
448
- // before flags could consume `resources`: the cached failure then
651
+ // blank container. Running Flags against an empty context instead
652
+ // lets an app whose Flags never touch the Layer boot as it did
653
+ // before Flags could consume `resources`: the cached failure then
449
654
  // surfaces at the first Command or Subscription, where `crashWith`
450
655
  // does render the crash view. Flags that do need the Layer still
451
656
  // fail here, and both causes are reported: the `Service not found`
452
657
  // defect the empty context produced is useless on its own, and the
453
658
  // build failure that explains it would be lost if it replaced the
454
- // flags cause outright. Combining them also keeps a flags Effect
659
+ // Flags cause outright. Combining them also keeps a Flags Effect
455
660
  // that fails for its own unrelated reason visible instead of
456
661
  // attributing its defect to the Layer. Interrupts propagate
457
662
  // untouched on both sides, because dispose racing either the build
458
- // or the flags run is not a failure to recover from, and
663
+ // or the Flags run is not a failure to recover from, and
459
664
  // `Effect.catchCause` hands the handler interrupt causes too.
460
665
  const provideResources = (effect) => Option.match(maybeAcquireResourceContext, {
461
666
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
@@ -471,23 +676,102 @@ const makeRuntime = ({ ports, Model, flags: maybeResolveFlags, init, update, vie
471
676
  onSuccess: resourceContext => Effect.provideContext(effect, resourceContext),
472
677
  }),
473
678
  });
474
- const resolveFlags = Option.match(maybeResolveFlags, {
475
- /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
476
- onNone: () => Effect.succeed(undefined),
679
+ const maybeResolveFreshFlags = Option.orElse(Option.fromNullishOr(bootFlags), () => configuredFlags);
680
+ const resolveFreshFlags = Option.match(maybeResolveFreshFlags, {
681
+ onNone: () => isFlagsRequired
682
+ ? Effect.die(new Error('[foldkit] This application declares Flags. Pass its ' +
683
+ 'Flags Effect to Runtime.run or Runtime.embed.'))
684
+ : /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
685
+ Effect.succeed(undefined),
477
686
  onSome: provideResources,
478
687
  });
688
+ // Every hydration refusal that knows which root it was going to adopt
689
+ // contains that root first. The build id is one reason to refuse; a
690
+ // missing, duplicated, malformed, or Schema-incompatible Flags payload
691
+ // is another, and the page left behind is just as live in each case.
692
+ const refuseHydration = (root, message, cause) => {
693
+ containRefusedPage(root.ownerDocument);
694
+ return Effect.die(cause === undefined
695
+ ? new Error(message)
696
+ : new Error(message, { cause }));
697
+ };
698
+ const decodeFlagsPayload = (payload, runtimeId, root) => Effect.try({
699
+ try: () => {
700
+ const parsedPayload = JSON.parse(payload);
701
+ return pipe(
702
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
703
+ Schema.toCodecJson(FlagsCodec), Schema.decodeUnknownSync, decode => decode(parsedPayload));
704
+ },
705
+ catch: cause => cause,
706
+ }).pipe(Effect.catch(cause => refuseHydration(root, '[foldkit] Runtime.hydrate could not decode the server ' +
707
+ `Flags payload for application "${runtimeId}". The HTML ` +
708
+ 'and client bundle must use the same Flags Schema.', cause)));
709
+ const maybeRequestedHydration = bootMode === 'Hydrate'
710
+ ? Option.fromNullishOr(hydration)
711
+ : Option.none();
712
+ if (bootMode === 'Hydrate' && Option.isNone(maybeRequestedHydration)) {
713
+ // A hydrating client that finds no stamped root will not adopt
714
+ // whatever the page holds, so the page is contained whether or not the
715
+ // caller named a container.
716
+ containRefusedPage(container === null ? document : container.ownerDocument);
717
+ return yield* Effect.die(new Error('[foldkit] Runtime.hydrate could not find a server-rendered ' +
718
+ `root stamped with \`${FOLDKIT_APP_ATTRIBUTE}\`. Use ` +
719
+ 'Runtime.run for a fresh client boot.'));
720
+ }
721
+ // The build the served page came from is settled here, before the Flags
722
+ // payload text is accessed, parsed, or decoded, before `init` runs, and
723
+ // therefore before any Command, Subscription, ManagedResource, or port
724
+ // this boot would start. A page from another deployment carries that
725
+ // deployment's Flags, which the current Schema may well accept while
726
+ // every value in them means something else, so deferring the comparison
727
+ // to the DOM patch lets stale data reach new code that already acted on
728
+ // it.
729
+ if (Option.isSome(maybeRequestedHydration)) {
730
+ const skew = buildSkew(maybeRequestedHydration.value.root, buildId, maybeRequestedHydration.value.runtimeId);
731
+ if (skew !== undefined) {
732
+ containRefusedPage(maybeRequestedHydration.value.root.ownerDocument);
733
+ return yield* Effect.die(skew);
734
+ }
735
+ }
736
+ // NOTE: an HMR-restored Model wins over DOM adoption because the
737
+ // server DOM reflects older code. The hydration handoff is still
738
+ // required, but the restored Model gets a fresh patch against its
739
+ // stamped root.
740
+ const maybeHydrationRoot = Predicate.isUndefined(hmrModel)
741
+ ? Option.map(maybeRequestedHydration, requestedHydration => requestedHydration.root)
742
+ : Option.none();
743
+ const maybeHydrationFlags = yield* Option.match(maybeRequestedHydration, {
744
+ onNone: () => Effect.succeed(Option.none()),
745
+ onSome: requestedHydration => Effect.map(requestedHydration.isFlagsRequired
746
+ ? Array.match(requestedHydration.flagsScripts, {
747
+ onEmpty: () => refuseHydration(requestedHydration.root, '[foldkit] Runtime.hydrate found application ' +
748
+ `"${requestedHydration.runtimeId}" but its ` +
749
+ 'server Flags payload is missing.'),
750
+ onNonEmpty: ([payloadScript, ...remainingScripts]) => Array.isArrayNonEmpty(remainingScripts)
751
+ ? refuseHydration(requestedHydration.root, '[foldkit] Runtime.hydrate found multiple ' +
752
+ 'server Flags payloads for application ' +
753
+ `"${requestedHydration.runtimeId}".`)
754
+ : decodeFlagsPayload(payloadScript.textContent ?? '', requestedHydration.runtimeId, requestedHydration.root),
755
+ })
756
+ : /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
757
+ Effect.succeed(undefined), Option.some),
758
+ });
759
+ const resolveFlags = Option.match(maybeHydrationFlags, {
760
+ onNone: () => resolveFreshFlags,
761
+ onSome: Effect.succeed,
762
+ });
479
763
  const ModelJsonCodec = Schema.toCodecJson(
480
764
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
481
765
  Model);
482
766
  const decodeHmrModel = Schema.decodeUnknownExit(ModelJsonCodec);
483
767
  const encodeHmrModel = Schema.encodeUnknownSync(ModelJsonCodec);
484
768
  const currentUrl = Option.fromNullishOr(routingConfig).pipe(Option.flatMap(() => urlFromString(window.location.href)));
485
- // NOTE: a restored Model skips `init`, so resolving flags on that
769
+ // NOTE: a restored Model skips `init`, so resolving Flags on that
486
770
  // path would build the `resources` Layer only to discard what it
487
771
  // produced. Gating the resolution on the restore decision is what
488
772
  // stops a reload from reconnecting whatever the Layer holds. It has
489
773
  // to stay ahead of the preserve-scheduler and HMR finalizers: a
490
- // flags Effect that fails after those are registered tears down more
774
+ // Flags Effect that fails after those are registered tears down more
491
775
  // than it used to, and their release defects would bury its cause.
492
776
  const runInit = Effect.map(resolveFlags, flags => init(flags, Option.getOrUndefined(currentUrl)));
493
777
  const [initModelRaw, initCommands] = yield* hmrModel !== undefined
@@ -637,6 +921,10 @@ const makeRuntime = ({ ports, Model, flags: maybeResolveFlags, init, update, vie
637
921
  }
638
922
  };
639
923
  const vnodeSlot = { maybeCurrentVNode: Option.none() };
924
+ // NOTE: consumed by the first render only. Set when this boot found
925
+ // an adoptable server-rendered root; the first patch then goes
926
+ // through `__hydrateVNode` instead of replacing the container.
927
+ let pendingHydrationRoot = Option.getOrNull(maybeHydrationRoot);
640
928
  // NOTE: registered before any perpetual fiber is forked so it runs
641
929
  // after they are interrupted (scope finalizers are LIFO). Patching to
642
930
  // an empty tree fires snabbdom destroy hooks, which is what releases
@@ -941,7 +1229,28 @@ const makeRuntime = ({ ports, Model, flags: maybeResolveFlags, init, update, vie
941
1229
  thresholdMs,
942
1230
  }));
943
1231
  const maybeCurrentVNode = vnodeSlot.maybeCurrentVNode;
944
- const [patchedVNode, maybePatchDuration] = yield* Effect.sync(() => measureSlowPhase(maybeLiveSlowPatch, () => __patchVNode(maybeCurrentVNode, nextVNode, container, boundaryRegistry.dedupeSeen)));
1232
+ const [patchedVNode, maybePatchDuration] = yield* Effect.sync(() => measureSlowPhase(maybeLiveSlowPatch, () => {
1233
+ if (Option.isNone(maybeCurrentVNode) &&
1234
+ pendingHydrationRoot !== null) {
1235
+ const hydrationRoot = pendingHydrationRoot;
1236
+ pendingHydrationRoot = null;
1237
+ // NOTE: strip the stamp before the patch, not after, so the
1238
+ // patch is the sole owner of the root's attributes. It has
1239
+ // already served its purpose of locating the root, and
1240
+ // removing it after would delete a `data-foldkit-app` the view
1241
+ // itself declares, which a later equal-vnode patch would not
1242
+ // restore. Removing it here also stops a later boot on the same
1243
+ // container (a dispose-then-embed remount) from re-detecting
1244
+ // this now-consumed root as hydratable.
1245
+ hydrationRoot.removeAttribute(FOLDKIT_APP_ATTRIBUTE);
1246
+ // An empty id reaches the adoption step's own check as a
1247
+ // value that matches nothing. Boot already refused a
1248
+ // hydration without an id, so this stands in only for a
1249
+ // caller that reached here another way.
1250
+ return __hydrateVNode(hydrationRoot, nextVNode, boundaryRegistry.dedupeSeen, buildId ?? '');
1251
+ }
1252
+ return __patchVNode(maybeCurrentVNode, nextVNode, container, boundaryRegistry.dedupeSeen);
1253
+ }));
945
1254
  vnodeSlot.maybeCurrentVNode = Option.some(patchedVNode);
946
1255
  reportSlowPhase(maybeLiveSlowPatch, maybePatchDuration, (durationMs, thresholdMs) => ({
947
1256
  _tag: 'Patch',
@@ -1372,10 +1681,15 @@ const makeRuntime = ({ ports, Model, flags: maybeResolveFlags, init, update, vie
1372
1681
  yield* Effect.never;
1373
1682
  })).pipe(Effect.provideService(RenderCommit, commitNotifier.service));
1374
1683
  };
1375
- const start = (hmrModel) => startWith(Option.none(), hmrModel);
1376
- const program = { runtimeId, start, ports };
1684
+ const start = (hmrModel) => startWith(Option.none(), hmrModel, 'Fresh');
1685
+ const program = {
1686
+ runtimeId,
1687
+ start,
1688
+ ports,
1689
+ };
1377
1690
  runtimeInternals.set(program, {
1378
- startWith,
1691
+ startWith: (maybeConnector, hmrModel, bootMode, flags, buildId) => startWith(maybeConnector, hmrModel, bootMode, flags, buildId),
1692
+ kind,
1379
1693
  isEmbedActive: false,
1380
1694
  maybeActiveFiber: Option.none(),
1381
1695
  });
@@ -1390,11 +1704,6 @@ const currentLocationUrl = () => {
1390
1704
  const { origin, pathname, search } = window.location;
1391
1705
  return `${origin}${pathname}${search}`;
1392
1706
  };
1393
- const textDirectionForHtmlElement = {
1394
- Ltr: 'ltr',
1395
- Rtl: 'rtl',
1396
- Auto: 'auto',
1397
- };
1398
1707
  const documentMetadataElements = new WeakMap();
1399
1708
  const metadataElementsForDocument = () => {
1400
1709
  let elements = documentMetadataElements.get(document);
@@ -1431,7 +1740,7 @@ const applyDocumentMetadata = (nextDocument, mountedRoot) => {
1431
1740
  documentElement.lang = nextDocument.lang;
1432
1741
  }
1433
1742
  if (nextDocument.dir !== undefined) {
1434
- const dir = textDirectionForHtmlElement[nextDocument.dir];
1743
+ const dir = textDirectionToAttribute(nextDocument.dir);
1435
1744
  if (documentElement.dir !== dir) {
1436
1745
  documentElement.dir = dir;
1437
1746
  }
@@ -1503,24 +1812,37 @@ const renderCrashView = (context, crash, container, vnodeSlot, manageDocument) =
1503
1812
  };
1504
1813
  export function makeApplication(config) {
1505
1814
  const { container } = config;
1506
- if (container === null) {
1815
+ const hasRouting = 'routing' in config;
1816
+ const hasFlags = 'Flags' in config;
1817
+ const hydration = findDocumentHydration(container, hasFlags);
1818
+ const resolvedContainer = hydration?.root ?? container;
1819
+ if (resolvedContainer === null) {
1820
+ // A server-rendered page whose root lost its stamp reaches exactly here:
1821
+ // template injection put the render where the placeholder was, so
1822
+ // `getElementById` finds nothing and the stamp that would have named the
1823
+ // root is gone. There is no handoff to refuse further along, and the markup
1824
+ // is as live as any other refused page, so it is contained here.
1825
+ if (hasServerRenderedMarkup(document)) {
1826
+ containRefusedPage(document);
1827
+ }
1507
1828
  throw new Error('[foldkit] Container is null. Make sure the element exists in the DOM ' +
1508
1829
  'before calling makeApplication (e.g. that your <div id="root"></div> has ' +
1509
- 'rendered, and your script runs after it).');
1830
+ 'rendered, and your script runs after it). On a server-rendered page ' +
1831
+ 'the runtime instead finds the root by its `data-foldkit-app` stamp.');
1510
1832
  }
1511
- const hasRouting = 'routing' in config;
1512
- const hasFlags = 'Flags' in config;
1513
1833
  const currentUrl = hasRouting
1514
1834
  ? Option.getOrThrow(urlFromString(window.location.href))
1515
1835
  : undefined;
1516
1836
  const baseConfig = {
1837
+ kind: 'Application',
1517
1838
  Model: config.Model,
1518
1839
  update: config.update,
1519
1840
  view: config.view,
1520
1841
  manageDocument: true,
1521
1842
  ports: config.ports,
1522
1843
  ...(config.subscriptions && { subscriptions: config.subscriptions }),
1523
- container,
1844
+ container: resolvedContainer,
1845
+ ...(hydration && { hydration }),
1524
1846
  ...(hasRouting && { routing: config.routing }),
1525
1847
  ...(config.crash && { crash: config.crash }),
1526
1848
  ...(Predicate.isNotUndefined(config.slow) && {
@@ -1548,7 +1870,8 @@ export function makeApplication(config) {
1548
1870
  return makeRuntime({
1549
1871
  ...baseConfig,
1550
1872
  Flags: config.Flags,
1551
- flags: Option.some(config.flags),
1873
+ configuredFlags: Option.none(),
1874
+ isFlagsRequired: true,
1552
1875
  init: (flags, url) => config.init(flags, url ?? currentUrl),
1553
1876
  });
1554
1877
  }
@@ -1556,7 +1879,8 @@ export function makeApplication(config) {
1556
1879
  return makeRuntime({
1557
1880
  ...baseConfig,
1558
1881
  Flags: Schema.Void,
1559
- flags: Option.none(),
1882
+ configuredFlags: Option.none(),
1883
+ isFlagsRequired: false,
1560
1884
  init: (_flags, url) => config.init(url ?? currentUrl),
1561
1885
  });
1562
1886
  }
@@ -1564,7 +1888,8 @@ export function makeApplication(config) {
1564
1888
  return makeRuntime({
1565
1889
  ...baseConfig,
1566
1890
  Flags: config.Flags,
1567
- flags: Option.some(config.flags),
1891
+ configuredFlags: Option.none(),
1892
+ isFlagsRequired: true,
1568
1893
  init: (flags) => config.init(flags),
1569
1894
  });
1570
1895
  }
@@ -1572,7 +1897,8 @@ export function makeApplication(config) {
1572
1897
  return makeRuntime({
1573
1898
  ...baseConfig,
1574
1899
  Flags: Schema.Void,
1575
- flags: Option.none(),
1900
+ configuredFlags: Option.none(),
1901
+ isFlagsRequired: false,
1576
1902
  init: () => config.init(),
1577
1903
  });
1578
1904
  }
@@ -1610,6 +1936,7 @@ export function makeElement(config) {
1610
1936
  });
1611
1937
  const crash = toCrashConfig(config.crash);
1612
1938
  const baseConfig = {
1939
+ kind: 'Element',
1613
1940
  Model: config.Model,
1614
1941
  update: config.update,
1615
1942
  view,
@@ -1640,7 +1967,8 @@ export function makeElement(config) {
1640
1967
  return makeRuntime({
1641
1968
  ...baseConfig,
1642
1969
  Flags: config.Flags,
1643
- flags: Option.some(config.flags),
1970
+ configuredFlags: Option.some(config.flags),
1971
+ isFlagsRequired: true,
1644
1972
  init: (flags) => config.init(flags),
1645
1973
  });
1646
1974
  }
@@ -1648,7 +1976,8 @@ export function makeElement(config) {
1648
1976
  return makeRuntime({
1649
1977
  ...baseConfig,
1650
1978
  Flags: Schema.Void,
1651
- flags: Option.none(),
1979
+ configuredFlags: Option.none(),
1980
+ isFlagsRequired: false,
1652
1981
  init: () => config.init(),
1653
1982
  });
1654
1983
  }
@@ -1716,6 +2045,20 @@ const resolveHmrModel = (runtimeId) => {
1716
2045
  return Effect.succeed(undefined);
1717
2046
  }));
1718
2047
  };
2048
+ /** Starts a program Effect with explicit boot inputs for runtime tests.
2049
+ * @internal */
2050
+ export const __startProgram = (program, hmrModel, bootMode, flags, buildId) => {
2051
+ const internals = runtimeInternals.get(program);
2052
+ if (Predicate.isUndefined(internals)) {
2053
+ return Effect.die(new Error('[foldkit] Runtime boot expects a program created by ' +
2054
+ 'makeApplication or makeElement.'));
2055
+ }
2056
+ if (bootMode === 'Hydrate' && internals.kind !== 'Application') {
2057
+ return Effect.die(new Error('[foldkit] Runtime.hydrate expects a program created by ' +
2058
+ 'makeApplication.'));
2059
+ }
2060
+ return internals.startWith(Option.none(), hmrModel, bootMode, flags, buildId);
2061
+ };
1719
2062
  // NOTE: deliberately not `BrowserRuntime.runMain`, which interrupts the
1720
2063
  // runtime on `beforeunload`. `beforeunload` is a question, not a commitment:
1721
2064
  // the browser also fires it for a click on a download link, for a navigation
@@ -1727,11 +2070,34 @@ const resolveHmrModel = (runtimeId) => {
1727
2070
  // interrupt at all and lets the document take the runtime with it. Error
1728
2071
  // reporting and the keep-alive interval come from `makeRunMain` either way.
1729
2072
  const runMainWithoutUnloadInterrupt = Runtime.makeRunMain(Function.constVoid);
1730
- /** Starts a Foldkit runtime that owns the page for the page's whole lifetime,
1731
- * with HMR support for development. To start a runtime under a
1732
- * host-controlled lifecycle instead, use `embed`. */
1733
- export const run = (program) => {
1734
- runMainWithoutUnloadInterrupt(provideBrowserScheduler(Effect.flatMap(resolveHmrModel(program.runtimeId), program.start)));
2073
+ const startProgram = (program, bootMode, flags, buildId) => {
2074
+ runMainWithoutUnloadInterrupt(provideBrowserScheduler(Effect.flatMap(resolveHmrModel(program.runtimeId), hmrModel => __startProgram(program, hmrModel, bootMode, flags, buildId))));
2075
+ };
2076
+ export function run(program, options) {
2077
+ startProgram(program, 'Fresh', options?.flags);
2078
+ }
2079
+ /** Starts a Foldkit runtime by adopting a server-rendered DOM in place instead
2080
+ * of building it fresh. Use this as the client entry for a page served by
2081
+ * `renderToString`: the first render attaches to the stamped root, keeps the
2082
+ * existing nodes, and reconstructs the Model from the Flags the server
2083
+ * embedded. The handoff is strict: a missing server root, a root stamped more
2084
+ * than once, more than one root with no container to choose between them, a
2085
+ * missing Flags payload, an undecodable payload, or a page from another
2086
+ * deployment terminates startup. Every one of those contains the page first:
2087
+ * the document's body is marked `inert` and a nondismissable modal shield is
2088
+ * opened above existing top-layer content, so native links, forms, controls,
2089
+ * and focus targets stop responding. Containment leaves author-owned dialogs
2090
+ * open without calling `close` or dispatching `cancel`. Nothing is moved, so
2091
+ * no custom element reconnects and no frame reloads. This is not a script or
2092
+ * event sandbox: existing capture-phase handlers, browser-generated top-layer
2093
+ * events, timers, and other stale scripts can still run and can open newer
2094
+ * top-layer UI. Use `run` in a separate client-only entry when the page should
2095
+ * boot without server output.
2096
+ *
2097
+ * @experimental Server rendering and hydration are experimental while their
2098
+ * contracts settle. */
2099
+ export const hydrate = (program, options) => {
2100
+ startProgram(program, 'Hydrate', undefined, options.buildId);
1735
2101
  };
1736
2102
  const buildPortHandles = (ports, connector) => {
1737
2103
  const handles = {};
@@ -1750,32 +2116,7 @@ const buildPortHandles = (ports, connector) => {
1750
2116
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1751
2117
  return handles;
1752
2118
  };
1753
- /**
1754
- * Starts a Foldkit runtime under a host-controlled lifecycle and returns an
1755
- * `EmbedHandle`. This is the entry point for embedding a Foldkit app inside
1756
- * another application: the host pushes values in through the handle's inbound
1757
- * Ports, listens to outbound Ports, and calls `dispose` when it unmounts the
1758
- * app. The host never touches the Model or dispatches Messages directly; the
1759
- * Schema-typed Ports are the whole boundary.
1760
- *
1761
- * Works with programs from both `makeApplication` and `makeElement`; for a
1762
- * widget on a page the host owns, `makeElement` is the natural fit.
1763
- *
1764
- * A program can be embedded once at a time (it owns one container). After
1765
- * `dispose`, the same container can be embedded again with a fresh program.
1766
- *
1767
- * ```ts
1768
- * const handle = Runtime.embed(element)
1769
- *
1770
- * handle.ports.stepChanged.send(5)
1771
- * const unsubscribe = handle.ports.countChanged.subscribe(count => {
1772
- * console.log(count)
1773
- * })
1774
- *
1775
- * handle.dispose()
1776
- * ```
1777
- */
1778
- export const embed = (program) => {
2119
+ export function embed(program, options) {
1779
2120
  const internals = runtimeInternals.get(program);
1780
2121
  if (Predicate.isUndefined(internals)) {
1781
2122
  throw new Error('[foldkit] embed expects a program created by makeApplication or makeElement.');
@@ -1795,7 +2136,7 @@ export const embed = (program) => {
1795
2136
  const startEffect = pipe(Option.match(internals.maybeActiveFiber, {
1796
2137
  onNone: () => Effect.void,
1797
2138
  onSome: previousFiber => Effect.asVoid(Fiber.await(previousFiber)),
1798
- }), Effect.andThen(resolveHmrModel(program.runtimeId)), Effect.flatMap(hmrModel => internals.startWith(Option.some(connector), hmrModel)));
2139
+ }), Effect.andThen(resolveHmrModel(program.runtimeId)), Effect.flatMap(hmrModel => internals.startWith(Option.some(connector), hmrModel, 'Fresh', options?.flags)));
1799
2140
  const fiber = Effect.runFork(provideBrowserScheduler(startEffect));
1800
2141
  internals.maybeActiveFiber = Option.some(fiber);
1801
2142
  let isHandleDisposed = false;
@@ -1810,4 +2151,4 @@ export const embed = (program) => {
1810
2151
  };
1811
2152
  const ports = buildPortHandles(program.ports, connector);
1812
2153
  return { ports, dispose };
1813
- };
2154
+ }