Skip to content
Tyler Marshall

Offering only the configurations that exist

A frame builder sells made-to-order bicycle frames whose valid combinations are a sparse tree rather than a grid, and every path a customer can finish has to end at something the shop can actually build.

Known limits

Tree size
The whole option tree is serialised into the page, so its weight scales with the catalog rather than with what the shopper selects.
Price range
Price ranges shown before a configuration is complete are authored alongside the tree, not read from the products behind it.
Stale leaves
Nothing verifies that a leaf still points at a product that exists.
Inventory
Availability is not part of the tree, so an unbuildable configuration is offered like any other.
Authoring
A theme cannot run validation when a merchant saves the tree, so a malformed branch is only detected when it is rendered.

Original reference implementation. Not client source code.

Why a client-side model

A made-to-order frame is chosen in four steps: model, then tubeset, then geometry treatment, then finish. The shape that matters is not the depth, it is the sparseness. The track frame is not offered in titanium. Full-custom geometry exists on two tubesets and not the third. The raw finish applies only to stainless. Most of the combinations the four levels imply do not exist and never will.

The platform's own answer is product variants, and it does not fit. A product carries at most three options, and this is four levels deep, so the tree does not go in at all. The ceiling is the least interesting objection though. The variant model is a dense grid: option values multiply, and the merchant deletes the rows that do not exist. Collapse this to three levels and you have a grid of mostly invalid combinations maintained by hand, regrowing every time someone adds a tubeset. Raising the variant limit from 100 to 2,048 made that grid bigger. It did not make it sparse.

Line item properties are the usual escape hatch, and they are the wrong one here. They are metadata carried alongside a line, and they do not change what the line costs. A titanium full-custom frame and a steel stock frame would reach checkout at the same amount, with the difference reconciled by a human afterwards. That is a displayed price the pricing path never saw, and it is the failure this repository is arranged around. Per-option pricing on the platform means variants, or it means a cart transform, and neither is a property.

A third-party configurator works, and plenty of merchants should use one. It takes the selection somewhere the theme cannot follow. Back-button behaviour, the store's own analytics, the breadcrumb trail and the accessible structure of the page all stop at the edge of markup you neither control nor test.

Rendering a page per step costs a round trip per choice, on a decision people explore rather than complete first time.

The model wins for one reason above the others: it never touches money. Its whole job is to walk merchant-authored data and resolve a complete path to a product that already exists. Pricing, inventory, tax and checkout stay exactly where they were, because what it hands back is a URL. A model that cannot misprice anything does not have to be trusted with prices. What that costs is on the plate above.

The tree, and what the theme hands the browser

The tree is authored by the merchant and projected into the page at render time, because finishes get added without a developer present.

Projected option tree
{
  "version": 1,
  "levels": ["model", "tubeset", "geometry", "finish"],
  "nodes": {
    "gravel": {
      "title": "Gravel frameset",
      "priceFrom": 2400,
      "children": {
        "steel": {
          "title": "Steel",
          "children": {
            "stock":  { "children": { "powder": { "sku": "GR-ST-STK-PWD" } } },
            "custom": { "children": { "powder": { "sku": "GR-ST-CUS-PWD" } } }
          }
        },
        "stainless": {
          "title": "Stainless",
          "children": {
            "stock": { "children": { "raw": { "sku": "GR-SS-STK-RAW" } } }
          }
        }
      }
    }
  }
}

The version marker is checked before anything else is read, and an unknown version is treated as no tree rather than parsed hopefully. A node is well formed when it has children or a leaf, never both. A branch that breaks either rule is marked unreachable rather than making the whole tree invalid, because one bad finish should not take the configurator down.

Pruning is the whole model

src/reachability.ts
/**
 * A value is offered only if a COMPLETE path to a leaf exists beneath it.
 *
 * The tempting version of this checks `Object.keys(node.children).length > 0`
 * and is wrong in a way that only shows up at the last step: a branch can have
 * children all the way down and still bottom out with no leaf, and the customer
 * finds that out after four clicks.
 */
export function annotateReachable(node: Node, depth: number, levels: number): boolean {
  if (depth > levels) return false;
 
  if (node.sku !== undefined) {
    node.reachable = node.children === undefined && node.sku !== '';
    return node.reachable;
  }
 
  if (node.children === undefined) {
    node.reachable = false;
    return false;
  }
 
  let any = false;
  for (const child of Object.values(node.children)) {
    if (annotateReachable(child, depth + 1, levels)) any = true;
  }
 
  node.reachable = any;
  return any;
}

The walk runs once when the tree loads, so every later transition is a lookup rather than a search. A subtree that bottoms out unreachable is pruned before it is ever offered, and a model whose every branch is unreachable is not offered at the top level at all.

That leaves a state the model has to name rather than treat as an error.

src/model.ts
type State =
  | { kind: 'idle' }
  | { kind: 'choosing'; path: string[]; level: number; offers: Offer[] }
  | { kind: 'resolved'; path: string[]; destination: string }
  | { kind: 'exhausted'; path: string[] };
 
/**
 * There is deliberately no way to produce a destination from a partial path.
 * `resolved` is constructible only from a path that terminates at a leaf, so no
 * caller can navigate a customer to a half-chosen frame by forgetting a null check.
 */
export function resolve(state: State): string | undefined {
  return state.kind === 'resolved' ? state.destination : undefined;
}
 
export function back(state: State, tree: Tree): State {
  if (state.kind !== 'choosing' || state.level === 0) return { kind: 'idle' };
  // Every selection below the level returned to is discarded. A retained deeper
  // selection can be invalid under a different branch, which is the bug this prevents.
  const path = state.path.slice(0, state.level - 1);
  return enter(tree, path);
}

exhausted exists so that a dead end is a state the view can render as a sentence rather than an empty step with a disabled button. And resolved being the only constructor of a destination is what makes a partially applied configuration unrepresentable, rather than merely discouraged.

Where it gives the wrong answer

The index card for a model shows a price before anything has been chosen, and that number is authored in the tree rather than read from the products behind the leaves.

So take the tree above, and a merchant who raised one price in the admin last week and did not touch the tree.

tree:    nodes.gravel.priceFrom = 2400
 
catalog: GR-ST-STK-PWD = 2400
         GR-ST-CUS-PWD = 2900
         GR-SS-STK-RAW = 2750    ← was 2400, raised in the admin

The card renders From $2,400. A merchant would say the correct figure is a floor computed from the live prices of the products behind the reachable leaves, which here is still $2,400 only because one steel path happens to hold that price. Raise that one too and the card is quoting a price no configuration meets.

Computing the true floor means loading every product behind every reachable leaf, for every model on the page, during the render of an index most visitors never configure anything on. A dozen models with a few dozen leaves apiece is several hundred product lookups in one response, inside a render budget that belongs to the platform rather than to you. The authored number costs one integer per model.

What keeps that from being a pricing bug is the boundary in the section above. The resolved destination is a real product URL, so the price the customer is shown at the point of decision, and charged at checkout, is the platform's own. The stale figure can misrepresent a floor on an index page. It can never misprice an order, because the model has no path by which a price it holds reaches a cart.

This does not override the platform's default behaviour, and that is deliberate. The variant picker is still the variant picker: the model resolves to a product and hands the customer straight to it for anything the grid can express, such as frame size. It is a front end onto the part of the decision the grid cannot hold, and it stops where the grid takes over. A configurator that swallowed the picker too would have to own pricing, and not owning pricing is the entire argument.

What the tests cover

The model is a pure function over a tree and a path, with no DOM, no network and no platform object, so the suite is plain assertions over fixture trees and runs with one command on a clean checkout. No store, no access token, no browser. The view is out of scope and the README says so, rather than the suite quietly growing a headless browser and stopping being runnable.

Seventeen properties. The ones worth naming: a branch whose children exist but whose descendants all dead-end is never offered, which is precisely the case a shallow check passes. A tree with exactly one complete path resolves immediately instead of walking a customer through three steps with one option each. Returning a level clears every selection below it. And no prefix of a known-good path can produce a destination, asserted by exhausting all of them.

Two tests carry the wrong answer, and they are written as a pair.

test/price-floor.test.ts
test('the displayed floor is the authored figure, not the catalog', () => {
  const model = load(TREE_WITH_STALE_FLOOR);
 
  // Documented-wrong on purpose. Every reachable leaf costs more than this.
  // Read "Where it gives the wrong answer" in the README before changing it.
  expect(model.offers[0].priceFrom).toBe(2400);
  expect(cheapestReachableLeafPrice(CATALOG)).toBe(2750);
});
 
test('and no price the model holds can reach a destination', () => {
  const model = load(TREE_WITH_STALE_FLOOR);
  const done = walk(model, ['gravel', 'stainless', 'stock', 'raw']);
 
  expect(resolve(done)).toBe('/products/gravel-frameset?variant=GR-SS-STK-RAW');
  expect(resolve(done)).not.toContain('2400');
  expect(done).not.toHaveProperty('price');
});

The first pins the boundary so nobody silently "fixes" the simplification without reading why it is there. The second pins the reason the boundary is survivable: the destination carries an identifier and no money, so the stale figure has nowhere to go. Both assert on the resolved destination rather than on a helper's return value, because that URL is the only thing this model actually produces.

Reading the source

Read the reachability walk first. It is twenty lines and it is the entire argument; the comment above it names the shallow version that looks equivalent and is not.

Then read the state type before any function that consumes it. The four states are doing the work that null checks would otherwise be doing badly, and the transitions only make sense once it is clear that resolved cannot be constructed from a partial path.

The tree loader can be skimmed. It is validation and defaults, with one thing worth stopping on: a malformed branch is pruned rather than thrown, and the reason is in the limitations on the plate above rather than in the code.

Want to see how this holds up in production?

The case studies are the same problems with a client, a deadline, and a legacy codebase attached.