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:
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:
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:
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:fieldsfor the parentSchema.Struct,casesfor itsdefineMessageUnion.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:updatethroughUpdate.foldChildStep, Subscriptions throughSubscription.lift, resources throughManagedResource.lift, and the view throughh.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:restgives exactly the fields no placement owns, each placement'sinitwrites its own slice, and each wiring'sinitruns after the placements', in list order. A placement whoseargsis a factory derives it from the base seed first (below). A placement's field may be given only when it holds anOption(below), to start the child asNone. The check reads each placement's field from its Link; one whose path the types cannot read, aLink.makegiven astring[], relaxesresttoPartial<Model>.placements.runtime(input)is the same assembled runtime config for an application whoseupdatealready routes every placement:initialrest becomesinit, or an init function returningassembly.initial(...)is used asinitwhen the seed needs runtime input, like the URL — never a full Model, so placement and wiring inits always run with their Commands. Theupdatepasses through checked; the ownsubscriptionsandmanagedResourcesmerge with the items', defaulting to the items', so an application that adds none passes neither. Assemblies that read the URL pass theirurlfromassembly.url, as withcomplete. 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 toown, whose Message is typed without the placements' wrappers (see Reacting to a child's Messages). A tag wirings declaresharedgoes to each of them in list order and then toown, so URL mirrors and the application's routing all seeUrlChanged. Withoutownthey leave the Model unchanged.placements.url(onUrlChange)is the runtime URL config:initapplies every wiring'sonUrlto the Model, and a URL change becomesonUrlChange's Message, which a wiring routes.placements.complete(config)returns the config unchanged. It exists to report wiring mistakes, below.