Bundle / foldkit-bundle

A Link without a Bundle: Update.foldChild

A page with a dozen @foldkit/ui components rarely wants a Bundle for each: each is one field, one Got*Message variant, and one line in update. Foldkit's Update.foldChild already folds such a child, given where it lives: read, write and toParentMessage. A Link is exactly those three, so spread one in and give the component's update:

ts
import * as Tabs from '@foldkit/ui/tabs'

const Section = Schema.Literals(['general', 'billing'])
type Section = typeof Section.Type
const SectionTabs = Tabs.create<Section>()

const Model = Schema.Struct({ tabs: Tabs.Model, section: Section })
type Model = typeof Model.Type
const Message = defineMessageUnion({ GotTabsMessage: { message: Tabs.Message }, Saved: {} })
type Message = typeof Message.Type

const tabs = Link.field<Model>()('tabs', Link.wrapper(Message.GotTabsMessage))

const foldTabs = Update.foldChild({
  ...tabs,
  update: SectionTabs.update,
  foldOutMessage: Tabs.OutMessage.match<Update.Step<Model, Message>, Tabs.OutMessage<Section>>({
    Selected:
      ({ value }) =>
      model => ({ model: { ...model, section: value } }),
  }),
})

const update = (model: Model, message: Message) =>
  Message.match<Update.Return<Model, Message>>(message, {
    GotTabsMessage: ({ message }) => foldTabs(model, message),
    Saved: () => ({ model }),
  })
  • Link.wrapper(Message.GotTabsMessage) builds the wrapper from a variant the parent's union already declares, so the tag is written once. A variant with fields beside message is refused, since a wrapped Message would lack them.
  • The same Link serves every entry point: Update.foldChildStep({ ...link, update: Dialog.open, foldOutMessage }) for one that takes no Message, and link.toParentMessage for the view's h.submodel and Subscription.lift. Its init folds through Link.foldInit, since init has no parent yet to read:
ts
const init = (): Update.Return<Model, Message> =>
  Update.foldChildInit(
    { model: Tabs.init({ id: 'sections' }) },
    Link.foldInit(tabs, { section: 'general' }),
  )
  • An ignored Message leaves the parent as it was. foldChild writes the child back even when its update returned the child it was given; the writes of Link.field and Link.optional then return the parent itself, so the page does not redraw. A write given to Link.make copies as written; placements guard it, foldChild does not.

Nothing here is placed: there is no placements.update routing, and the component's Subscriptions are lifted by hand. When a component is placed in several parents, or its Subscriptions are the part that gets forgotten, make it a Bundle with Bundle.fromParts above.

The two halves at once: Link.child

Hand-rolling foldChild plus an h.submodel restates the field, the wrapper, and the slot. Link.child(link, update, view, slotId) states them once and yields the fold for update and the drawing for view, so one placement drives both; an absent child (Link.optional) draws nothing.

ts
const posts = Link.child(postsLink, Posts.update, (page, draw) => PostsShell(page, draw), 'posts')

const update = (model: Model, message: Message) =>
  Message.match<Update.Return<Model, Message>>(message, {
    GotPostsMessage: ({ message }) => posts.update(model, message),
    Saved: () => ({ model }),
  })

const view = (model: Model, h: HtmlBuilder<Message>) => h.main([], [posts.view(model, h)])

A child with view inputs, or with OutMessages of its own, stays hand-rolled: Link.child takes a plain update and view and lifts neither.

test/foldChild.test.ts runs this example, and examples/foldkit/ui-showcase folds its thirty-eight components this way.

An ask that waits for its owner: Bundle.follow

An address can ask something of a child that has no Model yet — a block to select once the page loads, a preview to show once the entry arrives. Bundle.follow wraps an update result and, while a pending ask waits and its owner is ready, sends the ask through the owner's own Messages and lets it go:

ts
const follow = Bundle.follow(EditorPage.placed, {
  pending: model => model.linked,
  release: model => ({ ...model, linked: Option.none() }),
  ready: editor => editor.status !== 'Loading',
  toMessages: ask => [EditorMessage.Selected({ id: ask.block })],
})

const update = (model, message) => follow(baseUpdate(model, message))

Nothing happens without a pending ask, without the child, or before it is ready — each returns the result untouched, pending kept. An ask that translates to no Messages is still let go. Apply the step outside whatever installs a loaded value, so the owner looks ready when it is read; and keep the pending ask where the address can rewrite it while it waits, or a reload during the load loses it.

ready and toMessages also see the Model the ask was held in, for what only it knows. send replaces the dispatch when the ask must travel further than the placement — through the full update with its hooks, rather than the placement's own fold.