Skip to content

Setting a Default Screen

Left alone, the first thing a visitor sees is whatever the engine decides to place after the Chart’s launch measurement round-trip. This guide seeds a known first screen instead, using the greetingVessel from Layout Modes.

Nothing renders on <m.Chart /> until the engine responds. That first response comes from the Chart’s own launch chart_resize request — sent once the Chart measures its container — so the layout your visitor lands on depends on whatever the engine decides given an empty instance list and their particular viewport. Two visitors, two window sizes, two different first screens. For a lot of products that’s fine; for a dashboard or a tool with an obvious “home” state, it’s not — you want everyone to land in the same place.

Pass a TilingLayout to <m.Provider> via the initialLayout prop. It’s applied once, at construction, before the Chart ever measures its container or sends a request:

src/app.tsx
import {
InstanceId,
tilingLeafToken,
VariantName,
VesselId,
type TilingLayout,
} from '@maelstrom-co/protocol';
import { m } from './maelstrom';
import { myConnector } from './connector';
const LEFT = InstanceId('greeting-a');
const RIGHT = InstanceId('greeting-b');
const DEFAULT_LAYOUT: TilingLayout = {
type: 'tiling',
// Preorder: 'V' splits the region left/right; both children are leaves.
tree: ['V', tilingLeafToken(LEFT), tilingLeafToken(RIGHT)],
instances: [
{ instanceId: LEFT, vesselId: VesselId('greeting'), variant: VariantName('card') },
{ instanceId: RIGHT, vesselId: VesselId('greeting'), variant: VariantName('card') },
],
};
export default function App() {
return (
<m.Provider connector={myConnector} layoutMode="tiling" initialLayout={DEFAULT_LAYOUT}>
<m.Chart />
</m.Provider>
);
}

That seeds two greetingVessel instances side by side before any engine round-trip happens. Seeding is layout-only: unlike an engine response, which pairs each new instance with a configuring executionPlan, initialLayout runs no actions. The seeded instances render in their named variant with no engine-supplied configuration until the first response or a user action runs. tree is a preorder traversal of the tiling split tree, same encoding you saw described in Grid vs Tiling:

  • 'V' splits the current region left/right; 'H' splits it top/bottom. A bare token divides its region evenly in two.
  • 'V:<weights>' / 'H:<weights>' split the same axes in declared proportions, and the number of weights is the number of children: 'V:1,1,1' consumes the next three subtrees and gives each a third of the width, 'V:2,1' consumes two and gives the left one two thirds. See Split tokens and weights for the full grammar.
  • 'ID:' followed by an instance ID is a leaf — tilingLeafToken writes one for you.
  • null is an empty leaf — a blank pane.
  • Each instance ID appears at most once. To give an instance more room, weight its split instead of repeating the leaf.

InstanceId, VesselId, and VariantName are branded constructors from @maelstrom-co/protocol — the same ones you’d use to build any tiling layout by hand (see Programmatic Layout Control).

The engine sees your seeded instances plus the visitor’s real viewport in the very first chart request. It may resize the tiles, or drop one entirely, to make the layout fit. That’s not a bug to work around — it’s the only sane behavior. Your app has no way to know a given visitor’s window size ahead of time, so initialLayout can only ever propose a starting arrangement; the engine still reconciles it against the screen actually in front of the person using it.

initialLayout is typed TilingLayout — a grid layout isn’t accepted, not even as a union member. That follows from what a grid layout actually is: a GridPosition carries an instanceId plus a position and size — enough to place something, but not enough to create it. It has no vesselId or variant, so there’s no way to construct an instance from it. Applying a grid layout looks up each position’s instanceId against instances already in the store and repositions those; a position naming an instance it doesn’t know about is dropped with a warning rather than fabricated.

At the moment initialLayout is applied — Provider construction, before the first engine response — no instances exist yet. A grid layout would therefore seed nothing at all, so the client rejects it outright instead of silently accepting a value that can never do anything useful there.

Switching to grid after a seeded construction is a different thing, and it’s allowed. Mount with tiling plus an initialLayout, then flip layoutMode to "grid" on a later render: the Provider pushes the new mode into the live Orchestrator and nothing throws, because the seed was applied under the mode in force at the time. What you lose is the arrangement. A tiling tree and grid positions aren’t reinterpretable as each other, so the next Chart request carries a fresh, empty grid layout and the seeded Chart layout is discarded — the Engine never sees it and places your Instances from scratch. The client logs a warning naming both layout types when that happens. If you want the seeded screen back, remount under tiling with a new key, as below.

The grid/layoutMode rejections above aren’t the mistake you’re most likely to make — a typo in the DEFAULT_LAYOUT you just copy-pasted is. The Orchestrator validates a tiling initialLayout at construction time and throws on any of these:

  • A leaf’s vesselId isn’t registered (or failed vessel validation).
  • A leaf’s variant isn’t declared by that vessel.
  • instances contains a duplicate instanceId.
  • tree references an instanceId that isn’t in instances.
  • instances declares an instanceId that no tree leaf references — it would be created but never rendered.
  • tree names the same instanceId in two leaves — weight the split token instead of repeating the leaf.
  • A split token’s weight suffix is malformed — weights must be two to MAX_SPLIT_CHILDREN comma-separated integers from 1 to MAX_SPLIT_WEIGHT. A seed is rejected outright rather than repaired the way an engine response’s weights would be, so a typo like 'V:1,0,2' or 'V:01,1' throws instead of silently rendering something else.

Because this happens during construction, it fails before there’s an Orchestrator for <m.Provider> to hand to its children — the Provider renders nothing (its subtree stays blank) instead of the app you expected. Two things tell you why: the browser console logs [Maelstrom] Failed to create Orchestrator: followed by the error, and, if you passed an onError handler, it’s called with the same error.

initialLayout is read once, when the Provider mounts, and never again. Passing a new value on a later render is ignored — the console logs a warning so the mismatch doesn’t fail silently. (The warning compares the two layouts’ serialized contents, not the object reference, so re-passing a freshly-allocated but identical-looking layout on every render will not trigger it.)

To show a visitor a different default screen — a “reset to home” action, a different layout per route — remount the Provider with a new key:

src/app.tsx
<m.Provider key={screenId} connector={myConnector} initialLayout={layoutForScreen(screenId)}>
<m.Chart />
</m.Provider>

Bumping screenId tears down the old Provider and constructs a fresh one, which applies the new initialLayout at its own construction time.

Once you set a default screen, <m.Chart /> never starts empty — instances exist from the very first render, before the engine has said anything. If you have a component gated on “no instances yet” (a first-run hint, an onboarding overlay, empty-state copy), it stops rendering at launch, because the chart is no longer empty at launch. That’s expected: the whole point of initialLayout is to replace the empty state with something concrete. If you still want first-run guidance, key it off something else — a “has the visitor sent a message yet” flag, for instance — rather than instance count.