create-foldkit-app 0.38.0 → 0.39.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 (58) hide show
  1. package/dist/templates/base/FOLDKIT.md +3 -1
  2. package/dist/templates/examples/canvas-art/src/main.ts +1 -1
  3. package/dist/templates/examples/charting/src/chartHost.ts +3 -4
  4. package/dist/templates/examples/charting/src/entry.ts +0 -2
  5. package/dist/templates/examples/charting/src/view/chart.ts +101 -36
  6. package/dist/templates/examples/embedding/src/main.ts +3 -3
  7. package/dist/templates/examples/generative-art/src/subscription.ts +1 -1
  8. package/dist/templates/examples/job-application/src/step/education/education.ts +14 -16
  9. package/dist/templates/examples/job-application/src/step/skills/skills.ts +14 -16
  10. package/dist/templates/examples/job-application/src/step/workHistory/workHistory.ts +14 -16
  11. package/dist/templates/examples/map/src/entry.ts +1 -2
  12. package/dist/templates/examples/map/src/main.ts +123 -145
  13. package/dist/templates/examples/map/src/mapHost.ts +10 -12
  14. package/dist/templates/examples/map/src/mount.test.ts +93 -68
  15. package/dist/templates/examples/pixel-art/src/subscription.ts +5 -5
  16. package/dist/templates/examples/routing/src/main.ts +3 -3
  17. package/dist/templates/examples/slow-warnings/src/main.ts +4 -4
  18. package/dist/templates/examples/snake/src/main.ts +4 -3
  19. package/dist/templates/examples/ssg/src/entry.server.ts +2 -0
  20. package/dist/templates/examples/ssg/src/entry.ts +1 -0
  21. package/dist/templates/examples/ssr/package.json +1 -1
  22. package/dist/templates/examples/ssr/src/entry.server.ts +2 -0
  23. package/dist/templates/examples/ssr/src/entry.ts +1 -0
  24. package/dist/templates/examples/stopwatch/src/main.ts +1 -0
  25. package/dist/templates/examples/ui-showcase/src/main.ts +49 -37
  26. package/dist/templates/examples/ui-showcase/src/ui/subscriptions.ts +1 -18
  27. package/dist/templates/examples/ui-showcase/src/ui/update.ts +2 -12
  28. package/dist/templates/examples/ui-showcase/src/ui/view/calendar.ts +9 -7
  29. package/dist/templates/examples/ui-showcase/src/ui/view/checkbox.ts +80 -65
  30. package/dist/templates/examples/ui-showcase/src/ui/view/dialog.ts +102 -123
  31. package/dist/templates/examples/ui-showcase/src/ui/view/disclosure.ts +128 -120
  32. package/dist/templates/examples/ui-showcase/src/ui/view/fieldset.ts +173 -144
  33. package/dist/templates/examples/ui-showcase/src/ui/view/fileDrop.ts +17 -15
  34. package/dist/templates/examples/ui-showcase/src/ui/view/hoverIntent.ts +64 -56
  35. package/dist/templates/examples/ui-showcase/src/ui/view/input.ts +32 -33
  36. package/dist/templates/examples/ui-showcase/src/ui/view/meter.ts +82 -73
  37. package/dist/templates/examples/ui-showcase/src/ui/view/popover.ts +100 -81
  38. package/dist/templates/examples/ui-showcase/src/ui/view/progress.ts +83 -68
  39. package/dist/templates/examples/ui-showcase/src/ui/view/radioGroup.ts +79 -85
  40. package/dist/templates/examples/ui-showcase/src/ui/view/select.ts +63 -67
  41. package/dist/templates/examples/ui-showcase/src/ui/view/slider.ts +72 -72
  42. package/dist/templates/examples/ui-showcase/src/ui/view/switch.ts +25 -26
  43. package/dist/templates/examples/ui-showcase/src/ui/view/tabs.ts +58 -46
  44. package/dist/templates/examples/ui-showcase/src/ui/view/textarea.ts +32 -39
  45. package/dist/templates/examples/ui-showcase/src/ui/view/tooltip.ts +48 -36
  46. package/dist/templates/examples/websocket-chat/src/main.ts +44 -18
  47. package/dist/templates/examples/websocket-chat/src/story.test.ts +33 -6
  48. package/dist/templates/release.json +12 -11
  49. package/dist/templates/rendering/ssg/src/entry.server.ts +2 -0
  50. package/dist/templates/rendering/ssg/src/entry.ts +1 -0
  51. package/dist/templates/rendering/ssg/vite.config.ts +1 -0
  52. package/dist/templates/rendering/ssr/scripts/serve.ts +4 -145
  53. package/dist/templates/rendering/ssr/src/entry.server.ts +2 -0
  54. package/dist/templates/rendering/ssr/src/entry.ts +1 -0
  55. package/dist/templates/rendering/ssr/vite.config.ts +1 -0
  56. package/dist/utils/files.js +2 -0
  57. package/package.json +1 -1
  58. package/dist/templates/examples/charting/src/subscription.ts +0 -70
@@ -3,14 +3,13 @@ import {
3
3
  Array,
4
4
  Effect,
5
5
  Equal,
6
- Function,
7
6
  Option,
8
7
  Queue,
9
8
  Schema,
10
9
  Stream,
11
10
  String,
12
11
  } from 'effect'
13
- import { Command, Mount, Runtime, Subscription, Update } from 'foldkit'
12
+ import { Command, Mount, Runtime, Update } from 'foldkit'
14
13
  import * as Dom from 'foldkit/dom'
15
14
  import type { Document, Html } from 'foldkit/html'
16
15
  import { HtmlBuilder } from 'foldkit/html'
@@ -89,6 +88,12 @@ export const Message = defineMessageUnion({
89
88
 
90
89
  export type Message = typeof Message.Type
91
90
 
91
+ type MountMapMessage =
92
+ | typeof Message.SucceededMountMap.Type
93
+ | typeof Message.FailedMountMap.Type
94
+ | typeof Message.MovedMap.Type
95
+ | typeof Message.ClickedMarker.Type
96
+
92
97
  // COMMAND
93
98
 
94
99
  const flyToMap = (
@@ -310,101 +315,140 @@ export const init: Runtime.ApplicationInit<Model, Message> = () => ({
310
315
 
311
316
  // MAP MOUNT
312
317
 
313
- type MountedMap = Readonly<{
314
- map: MapInstance
315
- markerElements: ReadonlyArray<HTMLButtonElement>
316
- }>
317
-
318
- const applyMapViewState = (
319
- { map, markerElements }: MountedMap,
320
- viewState: Mount.ViewState,
321
- ): Effect.Effect<void> =>
322
- Effect.sync(() => {
323
- const isLive = viewState === 'Live'
324
-
325
- if (isLive) {
326
- map.keyboard.enable()
327
- } else {
328
- map.keyboard.disable()
329
- }
330
-
331
- Array.forEach(markerElements, markerElement => {
332
- markerElement.disabled = !isLive
333
- })
318
+ const toMapMountError = (error: unknown): Error =>
319
+ error instanceof Error ? error : new Error(`Failed to mount map: ${error}`)
320
+
321
+ const addLocationMarkers = (
322
+ map: MapInstance,
323
+ maplibre: typeof import('maplibre-gl'),
324
+ ) =>
325
+ Effect.try({
326
+ try: () =>
327
+ Array.forEach(featuredLocations, ({ id, lng, lat }) => {
328
+ const markerElement = document.createElement('button')
329
+ markerElement.setAttribute('data-location-id', id)
330
+ markerElement.setAttribute('aria-label', `Marker: ${id}`)
331
+ markerElement.className = markerStyle
332
+ new maplibre.Marker({ element: markerElement })
333
+ .setLngLat([lng, lat])
334
+ .addTo(map)
335
+ }),
336
+ catch: toMapMountError,
334
337
  })
335
338
 
336
- const mountMap = (
337
- element: Element,
338
- hostId: string,
339
- viewStateChanges: Stream.Stream<Mount.ViewState>,
339
+ const listenToMapMovesAndMarkerClicks = (
340
+ map: MapInstance,
341
+ queue: Queue.Enqueue<MountMapMessage>,
340
342
  ) =>
341
- Effect.gen(function* () {
342
- if (!(element instanceof HTMLElement)) {
343
- return Message.FailedMountMap({
344
- reason: 'Map host is not an HTMLElement.',
345
- })
346
- }
347
-
348
- return yield* Effect.gen(function* () {
349
- const mountedMap = yield* Effect.acquireRelease(
343
+ Effect.acquireRelease(
344
+ Effect.try({
345
+ try: () => {
346
+ const container = map.getContainer()
347
+ const onMoveEnd = () => {
348
+ Queue.offerUnsafe(
349
+ queue,
350
+ Message.MovedMap({ bounds: boundsFromMap(map) }),
351
+ )
352
+ }
353
+ const onContainerClick = (event: MouseEvent) => {
354
+ const target = event.target
355
+ if (!(target instanceof Element)) {
356
+ return
357
+ }
358
+ const marker = target.closest('[data-location-id]')
359
+ if (!(marker instanceof HTMLElement)) {
360
+ return
361
+ }
362
+ const locationId = marker.dataset['locationId']
363
+ if (locationId !== undefined) {
364
+ Queue.offerUnsafe(queue, Message.ClickedMarker({ locationId }))
365
+ }
366
+ }
367
+
368
+ try {
369
+ map.on('moveend', onMoveEnd)
370
+ container.addEventListener('click', onContainerClick)
371
+ return { container, onMoveEnd, onContainerClick }
372
+ } catch (error) {
373
+ map.off('moveend', onMoveEnd)
374
+ container.removeEventListener('click', onContainerClick)
375
+ throw error
376
+ }
377
+ },
378
+ catch: toMapMountError,
379
+ }),
380
+ ({ container, onMoveEnd, onContainerClick }) =>
381
+ Effect.sync(() => {
382
+ map.off('moveend', onMoveEnd)
383
+ container.removeEventListener('click', onContainerClick)
384
+ }),
385
+ )
386
+
387
+ const mountMap = (element: Element, hostId: string) =>
388
+ Stream.callback<MountMapMessage>(queue =>
389
+ Effect.gen(function* () {
390
+ if (!(element instanceof HTMLElement)) {
391
+ Queue.offerUnsafe(
392
+ queue,
393
+ Message.FailedMountMap({
394
+ reason: 'Map host is not an HTMLElement.',
395
+ }),
396
+ )
397
+ return yield* Effect.never
398
+ }
399
+
400
+ const mapResource = yield* Effect.acquireRelease(
350
401
  Effect.gen(function* () {
351
402
  const maplibre = yield* Effect.tryPromise(() => import('maplibre-gl'))
352
- maplibre.setWorkerUrl(maplibreWorkerUrl)
353
- const map = new maplibre.Map({
354
- container: element,
355
- style: 'https://demotiles.maplibre.org/style.json',
356
- center: [0, 20],
357
- zoom: INITIAL_MAP_ZOOM,
358
- })
359
-
360
- const markerElements = Array.map(
361
- featuredLocations,
362
- ({ id, lng, lat }) => {
363
- const markerElement = document.createElement('button')
364
- markerElement.setAttribute('data-location-id', id)
365
- markerElement.setAttribute('aria-label', `Marker: ${id}`)
366
- markerElement.className = markerStyle
367
- new maplibre.Marker({ element: markerElement })
368
- .setLngLat([lng, lat])
369
- .addTo(map)
370
- return markerElement
403
+ const map = yield* Effect.try({
404
+ try: () => {
405
+ maplibre.setWorkerUrl(maplibreWorkerUrl)
406
+ return new maplibre.Map({
407
+ container: element,
408
+ style: 'https://demotiles.maplibre.org/style.json',
409
+ center: [0, 20],
410
+ zoom: INITIAL_MAP_ZOOM,
411
+ })
371
412
  },
372
- )
373
-
374
- setMap(hostId, map)
375
- return { map, markerElements }
413
+ catch: toMapMountError,
414
+ })
415
+ return { map, maplibre }
376
416
  }),
377
- () => Effect.sync(() => removeMap(hostId)),
417
+ ({ map }) => Effect.sync(() => removeMap(hostId, map)),
378
418
  )
419
+ const { map, maplibre } = mapResource
379
420
 
380
- yield* viewStateChanges.pipe(
381
- Stream.runForEach(viewState =>
382
- applyMapViewState(mountedMap, viewState),
383
- ),
384
- Effect.forkScoped,
385
- )
421
+ yield* addLocationMarkers(map, maplibre)
422
+ setMap(hostId, map)
386
423
 
387
- return Message.SucceededMountMap({ hostId })
424
+ yield* listenToMapMovesAndMarkerClicks(map, queue)
425
+
426
+ Queue.offerUnsafe(queue, Message.SucceededMountMap({ hostId }))
427
+ Queue.offerUnsafe(queue, Message.MovedMap({ bounds: boundsFromMap(map) }))
428
+ return yield* Effect.never
388
429
  }).pipe(
389
430
  Effect.catch(error =>
390
- Effect.succeed(
391
- Message.FailedMountMap({
392
- reason: error instanceof Error ? error.message : `${error}`,
393
- }),
431
+ Effect.sync(() =>
432
+ Queue.offerUnsafe(
433
+ queue,
434
+ Message.FailedMountMap({ reason: toMapMountError(error).message }),
435
+ ),
394
436
  ),
395
437
  ),
396
- )
397
- })
438
+ ),
439
+ )
398
440
 
399
- export const MountMap = Mount.define('MountMap', {
441
+ export const MountMap = Mount.defineStream('MountMap', {
400
442
  args: { hostId: Schema.String },
401
- messages: [Message.SucceededMountMap, Message.FailedMountMap],
402
- execute: ({ element, hostId, viewStateChanges }) =>
403
- mountMap(element, hostId, viewStateChanges),
443
+ messages: [
444
+ Message.SucceededMountMap,
445
+ Message.FailedMountMap,
446
+ Message.MovedMap,
447
+ Message.ClickedMarker,
448
+ ],
449
+ execute: ({ element, hostId }) => mountMap(element, hostId),
404
450
  })
405
451
 
406
- // SUBSCRIPTIONS
407
-
408
452
  const boundsFromMap = (map: MapInstance): Bounds => {
409
453
  const bounds = map.getBounds()
410
454
  return {
@@ -415,72 +459,6 @@ const boundsFromMap = (map: MapInstance): Bounds => {
415
459
  }
416
460
  }
417
461
 
418
- const streamMapEvents = (hostId: string) =>
419
- Stream.callback<Message>(queue =>
420
- Effect.acquireRelease(
421
- Effect.sync(() =>
422
- Option.map(getMap(hostId), map => {
423
- const onMoveEnd = () => {
424
- Queue.offerUnsafe(
425
- queue,
426
- Message.MovedMap({ bounds: boundsFromMap(map) }),
427
- )
428
- }
429
-
430
- const onContainerClick = (event: MouseEvent) => {
431
- const target = event.target
432
- if (!(target instanceof Element)) {
433
- return
434
- }
435
- const marker = target.closest('[data-location-id]')
436
- if (!(marker instanceof HTMLElement)) {
437
- return
438
- }
439
- const locationId = marker.dataset['locationId']
440
- if (locationId !== undefined) {
441
- Queue.offerUnsafe(queue, Message.ClickedMarker({ locationId }))
442
- }
443
- }
444
-
445
- map.on('moveend', onMoveEnd)
446
- map.getContainer().addEventListener('click', onContainerClick)
447
- Queue.offerUnsafe(
448
- queue,
449
- Message.MovedMap({ bounds: boundsFromMap(map) }),
450
- )
451
-
452
- return { map, onMoveEnd, onContainerClick }
453
- }),
454
- ),
455
- maybeHandle =>
456
- Effect.sync(() =>
457
- Option.match(maybeHandle, {
458
- onNone: Function.constVoid,
459
- onSome: ({ map, onMoveEnd, onContainerClick }) => {
460
- map.off('moveend', onMoveEnd)
461
- map.getContainer().removeEventListener('click', onContainerClick)
462
- },
463
- }),
464
- ),
465
- ).pipe(Effect.flatMap(() => Effect.never)),
466
- )
467
-
468
- export const subscriptions = Subscription.make<Model, Message>()(entry => ({
469
- mapEvents: entry(
470
- { maybeMapHostId: Schema.Option(Schema.String) },
471
- {
472
- modelToDependencies: model => ({
473
- maybeMapHostId: model.maybeMapHostId,
474
- }),
475
- dependenciesToStream: ({ maybeMapHostId }) =>
476
- Option.match(maybeMapHostId, {
477
- onNone: () => Stream.empty,
478
- onSome: streamMapEvents,
479
- }),
480
- },
481
- ),
482
- }))
483
-
484
462
  // VIEW
485
463
 
486
464
  const HOST_ID = 'map-host-1'
@@ -1,11 +1,10 @@
1
- import { Function, Option } from 'effect'
1
+ import { Option } from 'effect'
2
2
  import type { Map as MapInstance } from 'maplibre-gl'
3
3
 
4
4
  const mapsByHostId = new Map<string, MapInstance>()
5
5
 
6
- /** Stash a live Map instance against an id so Subscriptions and Commands
7
- * can reach it without putting the (mutable, unfreezable) instance in the
8
- * Model. Cleanup is paired in `OnMount`'s cleanup. */
6
+ /** Stash a live Map instance against an id so Commands can reach it without
7
+ * putting the mutable, unfreezable instance in the Model. */
9
8
  export const setMap = (hostId: string, instance: MapInstance): void => {
10
9
  mapsByHostId.set(hostId, instance)
11
10
  }
@@ -13,11 +12,10 @@ export const setMap = (hostId: string, instance: MapInstance): void => {
13
12
  export const getMap = (hostId: string): Option.Option<MapInstance> =>
14
13
  Option.fromNullishOr(mapsByHostId.get(hostId))
15
14
 
16
- export const removeMap = (hostId: string): void =>
17
- Option.match(getMap(hostId), {
18
- onNone: Function.constVoid,
19
- onSome: map => {
20
- map.remove()
21
- mapsByHostId.delete(hostId)
22
- },
23
- })
15
+ export const removeMap = (hostId: string, map: MapInstance): void => {
16
+ map.remove()
17
+
18
+ if (mapsByHostId.get(hostId) === map) {
19
+ mapsByHostId.delete(hostId)
20
+ }
21
+ }
@@ -1,11 +1,11 @@
1
- import { Deferred, Effect, PubSub, Stream } from 'effect'
2
- import { Mount } from 'foldkit'
1
+ import { Deferred, Effect, Stream } from 'effect'
3
2
  import { beforeEach, describe, expect, test, vi } from 'vitest'
4
3
 
5
- import { MountMap } from './main'
4
+ import { Message, MountMap } from './main'
6
5
 
7
6
  const maplibre = vi.hoisted(() => {
8
7
  let container: HTMLElement | undefined
8
+ let onMoveEnd: (() => void) | undefined
9
9
 
10
10
  return {
11
11
  get container(): HTMLElement | undefined {
@@ -14,8 +14,15 @@ const maplibre = vi.hoisted(() => {
14
14
  set container(nextContainer: HTMLElement | undefined) {
15
15
  container = nextContainer
16
16
  },
17
- disableKeyboard: vi.fn(),
18
- enableKeyboard: vi.fn(),
17
+ clearEventHandlers: (): void => {
18
+ onMoveEnd = undefined
19
+ },
20
+ emitMoveEnd: (): void => {
21
+ onMoveEnd?.()
22
+ },
23
+ setMoveEndListener: (listener: () => void): void => {
24
+ onMoveEnd = listener
25
+ },
19
26
  makeMap: vi.fn(),
20
27
  removeMap: vi.fn(),
21
28
  }
@@ -23,11 +30,6 @@ const maplibre = vi.hoisted(() => {
23
30
 
24
31
  vi.mock('maplibre-gl', () => {
25
32
  class Map {
26
- readonly keyboard = {
27
- disable: maplibre.disableKeyboard,
28
- enable: maplibre.enableKeyboard,
29
- }
30
-
31
33
  constructor({ container }: { container: HTMLElement }) {
32
34
  maplibre.container = container
33
35
  maplibre.makeMap()
@@ -36,6 +38,31 @@ vi.mock('maplibre-gl', () => {
36
38
  remove(): void {
37
39
  maplibre.removeMap()
38
40
  }
41
+
42
+ on(eventName: string, listener: () => void): this {
43
+ if (eventName === 'moveend') {
44
+ maplibre.setMoveEndListener(listener)
45
+ }
46
+ return this
47
+ }
48
+
49
+ off(): this {
50
+ maplibre.clearEventHandlers()
51
+ return this
52
+ }
53
+
54
+ getContainer(): HTMLElement {
55
+ return maplibre.container ?? document.createElement('div')
56
+ }
57
+
58
+ getBounds() {
59
+ return {
60
+ getWest: () => -180,
61
+ getSouth: () => -85,
62
+ getEast: () => 180,
63
+ getNorth: () => 85,
64
+ }
65
+ }
39
66
  }
40
67
 
41
68
  class Marker {
@@ -58,70 +85,68 @@ vi.mock('maplibre-gl', () => {
58
85
  return { Map, Marker, setWorkerUrl: vi.fn() }
59
86
  })
60
87
 
88
+ const mountAndInteract = (host: HTMLElement): Effect.Effect<Array<Message>> =>
89
+ Effect.scoped(
90
+ Effect.gen(function* () {
91
+ const received: Array<Message> = []
92
+ const mounted = yield* Deferred.make<void>()
93
+
94
+ yield* MountMap({ hostId: 'test-map-host' })
95
+ .f(host, Stream.empty)
96
+ .pipe(
97
+ Stream.runForEach(message =>
98
+ Effect.sync(() => {
99
+ received.push(message)
100
+ if (message._tag === 'MovedMap') {
101
+ Effect.runSync(Deferred.succeed(mounted, undefined))
102
+ }
103
+ }),
104
+ ),
105
+ Effect.forkScoped,
106
+ )
107
+
108
+ yield* Deferred.await(mounted)
109
+ const marker = host.querySelector('[data-location-id]')
110
+ if (marker === null) {
111
+ throw new Error('Expected a mounted marker')
112
+ }
113
+
114
+ marker.dispatchEvent(new MouseEvent('click', { bubbles: true }))
115
+ maplibre.emitMoveEnd()
116
+ yield* Effect.yieldNow
117
+
118
+ expect(received.map(message => message._tag)).toEqual([
119
+ 'SucceededMountMap',
120
+ 'MovedMap',
121
+ 'ClickedMarker',
122
+ 'MovedMap',
123
+ ])
124
+ return received
125
+ }),
126
+ )
127
+
61
128
  describe('MountMap', () => {
62
129
  beforeEach(() => {
63
130
  maplibre.container = undefined
131
+ maplibre.clearEventHandlers()
64
132
  vi.clearAllMocks()
65
133
  })
66
134
 
67
- test('makes the surviving map read-only while its view is paused', async () => {
68
- const host = document.createElement('div')
69
-
70
- await Effect.runPromise(
71
- Effect.scoped(
72
- Effect.gen(function* () {
73
- const observedInitialLive = yield* Deferred.make<void>()
74
- const observedPaused = yield* Deferred.make<void>()
75
- const observedResumed = yield* Deferred.make<void>()
76
- maplibre.enableKeyboard.mockImplementation(() => {
77
- if (maplibre.enableKeyboard.mock.calls.length === 1) {
78
- Effect.runSync(Deferred.succeed(observedInitialLive, undefined))
79
- } else if (maplibre.enableKeyboard.mock.calls.length === 2) {
80
- Effect.runSync(Deferred.succeed(observedResumed, undefined))
81
- }
82
- })
83
- maplibre.disableKeyboard.mockImplementation(() => {
84
- Effect.runSync(Deferred.succeed(observedPaused, undefined))
85
- })
86
-
87
- const viewStates = yield* PubSub.unbounded<Mount.ViewState>({
88
- replay: 1,
89
- })
90
- yield* PubSub.publish(viewStates, Mount.ViewState.make('Live'))
91
- yield* MountMap({ hostId: 'test-map-host' })
92
- .f(host, Stream.fromPubSub(viewStates))
93
- .pipe(Stream.runDrain, Effect.forkScoped)
94
-
95
- yield* Deferred.await(observedInitialLive)
96
- yield* Effect.yieldNow
97
- expect(maplibre.makeMap).toHaveBeenCalledOnce()
98
- const markers = host.querySelectorAll('button[data-location-id]')
99
- expect(markers.length).toBeGreaterThan(0)
100
- for (const marker of markers) {
101
- expect(marker).toHaveProperty('disabled', false)
102
- }
103
-
104
- yield* PubSub.publish(viewStates, Mount.ViewState.make('Paused'))
105
- yield* Deferred.await(observedPaused)
106
- yield* Effect.yieldNow
107
- expect(maplibre.makeMap).toHaveBeenCalledOnce()
108
- for (const marker of markers) {
109
- expect(marker).toHaveProperty('disabled', true)
110
- }
111
-
112
- yield* PubSub.publish(viewStates, Mount.ViewState.make('Live'))
113
- yield* Deferred.await(observedResumed)
114
- yield* Effect.yieldNow
115
- expect(maplibre.makeMap).toHaveBeenCalledOnce()
116
- for (const marker of markers) {
117
- expect(marker).toHaveProperty('disabled', false)
118
- }
119
- }),
120
- ),
121
- )
122
-
123
- expect(maplibre.disableKeyboard).toHaveBeenCalledOnce()
124
- expect(maplibre.enableKeyboard).toHaveBeenCalledTimes(2)
135
+ test('rebinds map events when the same host id remounts', async () => {
136
+ const firstHost = document.createElement('div')
137
+ const firstMessages = await Effect.runPromise(mountAndInteract(firstHost))
125
138
  expect(maplibre.removeMap).toHaveBeenCalledOnce()
139
+
140
+ maplibre.emitMoveEnd()
141
+ firstHost
142
+ .querySelector('[data-location-id]')
143
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
144
+ expect(firstMessages).toHaveLength(4)
145
+
146
+ const secondHost = document.createElement('div')
147
+ await Effect.runPromise(mountAndInteract(secondHost))
148
+
149
+ expect(maplibre.makeMap).toHaveBeenCalledTimes(2)
150
+ expect(maplibre.removeMap).toHaveBeenCalledTimes(2)
126
151
  })
127
152
  })
@@ -1,5 +1,5 @@
1
1
  import { Effect, Match, Option, Schema, Stream } from 'effect'
2
- import { Subscription } from 'foldkit'
2
+ import { Dom, Subscription } from 'foldkit'
3
3
 
4
4
  import { Message } from './message'
5
5
  import type { Model } from './model'
@@ -39,16 +39,16 @@ const toToolMessage = (event: KeyboardEvent): Option.Option<Message> => {
39
39
  }
40
40
 
41
41
  export const subscriptions = Subscription.make<Model, Message>()(entry => ({
42
- undoRedoKeys: Subscription.persistent(
43
- Subscription.fromEventFilterMapPreventDefault({
42
+ undoRedoKeys: Subscription.persistentEntry(
43
+ Dom.streamFromEventFilterMapPreventDefault({
44
44
  target: document,
45
45
  type: 'keydown',
46
46
  filterMapEvent: toUndoRedoMessage,
47
47
  }),
48
48
  ),
49
49
 
50
- toolKeys: Subscription.persistent(
51
- Subscription.fromEventFilterMap({
50
+ toolKeys: Subscription.persistentEntry(
51
+ Dom.streamFromEventFilterMap({
52
52
  target: document,
53
53
  type: 'keydown',
54
54
  filterMapEvent: toToolMessage,
@@ -1,5 +1,5 @@
1
1
  import { Array, Effect, Match, Option, Schema } from 'effect'
2
- import { Command, Runtime, Subscription, Update } from 'foldkit'
2
+ import { Command, Dom, Runtime, Subscription, Update } from 'foldkit'
3
3
  import { Document, Html, HtmlBuilder } from 'foldkit/html'
4
4
  import { defineMessageUnion } from 'foldkit/message'
5
5
  import { UrlRequest, load, pushUrl } from 'foldkit/navigation'
@@ -160,8 +160,8 @@ export const update = (model: Model, message: Message) =>
160
160
  // SUBSCRIPTION
161
161
 
162
162
  export const subscriptions = Subscription.make<Model, Message>()(() => ({
163
- keyBindings: Subscription.persistent(
164
- Subscription.keyBindings<Message>({
163
+ keyBindings: Subscription.persistentEntry(
164
+ Dom.streamFromKeyBindings<Message>({
165
165
  bindings: [
166
166
  {
167
167
  keys: ['G', 'H'],
@@ -1,5 +1,5 @@
1
1
  import { Array, Match, Number, Option, Schema, Stream, pipe } from 'effect'
2
- import { Runtime, Subscription, type Update } from 'foldkit'
2
+ import { Dom, Runtime, Subscription, type Update } from 'foldkit'
3
3
  import { type Document, type Html, HtmlBuilder, createLazy } from 'foldkit/html'
4
4
  import { defineMessageUnion } from 'foldkit/message'
5
5
  import { modifyFields } from 'foldkit/struct'
@@ -61,7 +61,7 @@ export const Message = defineMessageUnion({
61
61
 
62
62
  export type Message = typeof Message.Type
63
63
 
64
- const slowWarningTarget: Subscription.TypedEventTarget<{
64
+ const slowWarningTarget: Dom.TypedEventTarget<{
65
65
  [SLOW_WARNING_EVENT]: CustomEvent<SlowWarningReport>
66
66
  }> = new EventTarget()
67
67
 
@@ -219,8 +219,8 @@ export const init: Runtime.ApplicationInit<Model, Message> = () => ({
219
219
  // SUBSCRIPTION
220
220
 
221
221
  export const subscriptions = Subscription.make<Model, Message>()(entry => ({
222
- slowWarnings: Subscription.persistent(
223
- Subscription.fromEventFilterMap({
222
+ slowWarnings: Subscription.persistentEntry(
223
+ Dom.streamFromEventFilterMap({
224
224
  target: slowWarningTarget,
225
225
  type: SLOW_WARNING_EVENT,
226
226
  filterMapEvent: event =>
@@ -8,7 +8,7 @@ import {
8
8
  Stream,
9
9
  pipe,
10
10
  } from 'effect'
11
- import { Command, Runtime, Subscription, type Update } from 'foldkit'
11
+ import { Command, Dom, Runtime, Subscription, type Update } from 'foldkit'
12
12
  import { Document, Html, HtmlBuilder } from 'foldkit/html'
13
13
  import { defineMessageUnion } from 'foldkit/message'
14
14
  import { modifyFields } from 'foldkit/struct'
@@ -244,6 +244,7 @@ export const subscriptions = Subscription.make<Model, Message>()(entry => ({
244
244
  dependenciesToStream: ({ isPlaying, interval }) =>
245
245
  Stream.when(
246
246
  Stream.tick(Duration.millis(interval)).pipe(
247
+ Stream.drop(1),
247
248
  Stream.map(Message.TickedClock),
248
249
  ),
249
250
  Effect.sync(() => isPlaying),
@@ -251,8 +252,8 @@ export const subscriptions = Subscription.make<Model, Message>()(entry => ({
251
252
  },
252
253
  ),
253
254
 
254
- keyboard: Subscription.persistent(
255
- Subscription.fromEventFilterMapPreventDefault({
255
+ keyboard: Subscription.persistentEntry(
256
+ Dom.streamFromEventFilterMapPreventDefault({
256
257
  target: document,
257
258
  type: 'keydown',
258
259
  filterMapEvent: keyboardEvent =>
@@ -3,6 +3,8 @@ import { Server } from 'foldkit/experimental'
3
3
 
4
4
  import { init, view } from './main'
5
5
 
6
+ export const renderDocument = Server.renderDocument
7
+
6
8
  export const prerenderPaths: ReadonlyArray<string> = ['/', '/about']
7
9
 
8
10
  export const renderPage = (request: Request): Promise<Server.EntryResult> =>
@@ -1,6 +1,7 @@
1
1
  import { Runtime } from 'foldkit'
2
2
 
3
3
  import { Message, Model, init, update, view } from './main'
4
+ import './styles.css'
4
5
 
5
6
  const application = Runtime.makeApplication({
6
7
  Model,