Bundle / foldkit-bundle

Adding subscriptions: one media query, placed twice

Define it once. It is the Model, Message, init, update, and Subscriptions a Submodel would have, collected into one value. args is a Schema, so init, update, and subscriptions are typed without annotations. matchMediaChanges stands for your own Stream<boolean> over matchMedia:

ts
import { Schema, Stream } from 'effect'
import { defineMessageUnion } from 'foldkit/message'
import * as Subscription from 'foldkit/subscription'
import { Bundle } from 'foldkit-bundle'

const MediaQueryModel = Schema.Struct({ matches: Schema.Boolean })
type MediaQueryModel = typeof MediaQueryModel.Type
const MediaQueryMessage = defineMessageUnion({ Changed: { matches: Schema.Boolean } })
type MediaQueryMessage = typeof MediaQueryMessage.Type

export const MediaQuery = Bundle.make('MediaQuery', {
  Model: MediaQueryModel,
  Message: MediaQueryMessage,
  args: Schema.Struct({ query: Schema.String }),
  init: () => ({ model: { matches: false } }),
  update: (_model, message) => ({ model: { matches: message.matches } }),
  subscriptions: ({ query }) =>
    Subscription.make<MediaQueryModel, MediaQueryMessage>()(() => ({
      changes: Subscription.persistentEntry(
        Stream.map(matchMediaChanges(query), matches => MediaQueryMessage.Changed({ matches })),
      ),
    })),
})

Place it, twice. A declaration gives each placement its Model field and its Message variant, named by Foldkit's Got<Field>Message convention:

ts
const Dark = Bundle.declare(MediaQuery, 'dark') // wrapper GotDarkMessage
const Narrow = Bundle.declare(MediaQuery, 'narrow') // wrapper GotNarrowMessage

const Model = Schema.Struct({ ...Dark.fields, ...Narrow.fields, helpOpen: Schema.Boolean })
type Model = typeof Model.Type
const Message = defineMessageUnion({ ...Dark.cases, ...Narrow.cases, ClickedHelp: {} })
type Message = typeof Message.Type

const Page = Bundle.parent({ Model, Message })

const placements = Page.assemble(
  Page.at(Dark, { args: { query: '(prefers-color-scheme: dark)' } }),
  Page.at(Narrow, { args: { query: '(max-width: 40rem)' } }),
)

Wire it once. The assembly builds the parent's runtime config from the parent's own pieces. initial gives exactly the fields no placement owns, update is the parent's own update for ClickedHelp routed through the assembly, and everything else (Model, view, ...) passes through:

ts
const config = placements.runtime({
  Model,
  initial: { helpOpen: false },
  update: placements.update(ownUpdate),
  view,
})

Spread config into Runtime.makeApplication or Runtime.makeElement with the rest of your options. The parent's own Subscriptions go in subscriptions and its own Managed Resources in managedResources; the assembly merges them with the items', defaulting to the items' when omitted. Assemblies that read the URL pass their url from placements.url. For a hand-built init or config, placements.complete checks it instead (below).

What each call does

  • Bundle.make(name, spec) only collects the parts. It runs nothing and holds no state. Bundle.make({ name, ...spec }) is the same.
  • Bundle.compose(fields).pipe(...) does the next four steps from one declaration; see Composing a parent.
  • Bundle.declare(bundle, field) names where a placement will live: fields for the parent Schema.Struct, cases for its defineMessageUnion.
  • Bundle.parent({ Model, Message }) states the parent once, as the Schemas it already has, so nothing after it takes a type argument.
  • Page.at(declared, config) places the bundle. It checks that the field holds the bundle's Model and that the parent Message has the wrapper variant, and lifts every part: update through Update.foldChildStep, Subscriptions through Subscription.lift, resources through ManagedResource.lift, and the view through h.submodel. It performs no I/O.
  • Page.assemble(...items) is the one list of what joins the parent: placements, collections, and integration wiring. An integration's wiring brings its routing, startup step, Subscriptions, and contract the same way a placement brings its parts; see Wiring.
  • placements.initial(rest) is the parent's initial Model and Commands: rest gives exactly the fields no placement owns, each placement's init writes its own slice, and each wiring's init runs after the placements', in list order. A placement whose args is a factory derives it from the base seed first (below). A placement's field may be given only when it holds an Option (below), to start the child as None. The check reads each placement's field from its Link; one whose path the types cannot read, a Link.make given a string[], relaxes rest to Partial<Model>.
  • placements.runtime(input) is the same assembled runtime config for an application whose update already routes every placement: initial rest becomes init, or an init function returning assembly.initial(...) is used as init when the seed needs runtime input, like the URL — never a full Model, so placement and wiring inits always run with their Commands. The update passes through checked; the own subscriptions and managedResources merge with the items', defaulting to the items', so an application that adds none passes neither. Assemblies that read the URL pass their url from assembly.url, as with complete. To route a narrow own update instead of a pre-routed one, compose it first: update: assembly.update(own).
  • placements.update(own) is the parent's update: a placement's or wiring's Message goes to its item and every other Message to own, whose Message is typed without the placements' wrappers (see Reacting to a child's Messages). A tag wirings declare shared goes to each of them in list order and then to own, so URL mirrors and the application's routing all see UrlChanged. Without own they leave the Model unchanged.
  • placements.url(onUrlChange) is the runtime URL config: init applies every wiring's onUrl to the Model, and a URL change becomes onUrlChange's Message, which a wiring routes.
  • placements.complete(config) returns the config unchanged. It exists to report wiring mistakes, below.