Layer Linearisation


This note describes how Infusion 6 orders the layers of a component: how the $layers lists written by authors are linearised into the single list held at that.$layers, and so which layer’s contributions win a merge. It records the decisions taken in FLUID-5743, which made a layer listed further right beat those to its left, together with a relaxation of the C3 consistency rule and an assessment of its consequences against the “pedalo” example of Barrett et al., A Monotonic Superclass Linearization for Dylan (OOPSLA 1996).

The raw markdown underlying these docs is available on GitHub, suitable for direct ingestion by your friendly local neighbourhood intelligence.

The implementation is in Fluid.js (fluid.C3_merge, fluid.C3_precedence, fluid.C3_precedence_parents and the hierarchy resolver) and FluidIL.js (merge records and subcomponent records). Tests are in tests/framework-tests/core/js/FluidJSTests.js, under “C3 linearisation for FLUID-5800”.

The rule as authors see it

  • Every list of layer names is read weakest first: a layer listed later in $layers beats one listed earlier.
  • A layer always beats the layers it inherits from.
  • The resolved list at that.$layers is also weakest first, so it reads in the same direction as it is written.
fluid.def("my.shape", {$layers: "fluid.component", sides: 0, colour: "black"});
fluid.def("my.red", {$layers: "fluid.component", colour: "red"});
fluid.def("my.triangle", {$layers: "my.shape", sides: 3});
fluid.def("my.redTriangle", {$layers: ["my.triangle", "my.red"]});

const t = my.redTriangle();
t.colour;    // "red" - my.red is listed after my.triangle, and so beats it and my.shape
t.$layers;   // ["fluid.component", "my.shape", "my.triangle", "my.red", "my.redTriangle"]

This reverses the order used by Infusion 2 to 5, in which the leftmost entry won. FLUID-5743 reported that users generally expect the opposite, matching the reading order of CSS cascades and of most mixin systems.

C3 underneath

The linearisation itself is C3, as used by Dylan and by Python’s method resolution order. C3 merges

  • the linearisations of each of a layer’s parents, and
  • the layer’s own parent list (its local precedence order),

taking at each step the first head of any list which appears in no other list’s tail. If no such head exists, the hierarchy is inconsistent. C3 is monotonic: a layer’s linearisation extends each of its parents’ linearisations without reordering them, and it respects every layer’s local precedence order.

C3 conventionally works strongest first. Infusion therefore reverses each $layers list before supplying it to C3, and reverses the result, so that the algorithm is unchanged and only the reading direction of the lists differs.

Two strengths of ordering

  • Within a def, a layer’s $layers list is a hard constraint, as above. Two parents whose own hierarchies order some pair of layers in opposite ways cannot be reconciled, and fail with “Inconsistent hierarchy”.
  • At the top level of a component, the list made up of its layer, any $layers from a creator function’s argument, the layers named by its subcomponent records and so on, is a preference only. fluid.C3_precedence_parents merges just the linearisations of the listed layers, not the list itself, so a misordered list here never fails. It gives way wherever the layers’ own hierarchies require it.

Ignoring a restated ancestor

A list which names a layer to the right of another layer that already inherits from it asks for the ancestor to beat its own descendant. For example, fluid.resolveRoot inherits from fluid.component, so in

$layers: ["fluid.resolveRoot", "fluid.component"]

fluid.component is asked to beat fluid.resolveRoot, which C3 cannot honour. Such a restatement is now ignored rather than failing. The descendant’s own hierarchy already places the ancestor beneath it, and the author’s intent, that both layers apply, is clear.

Concretely, fluid.C3_precedence filters a def’s parents before merging them. Working strongest first, a parent is dropped if it appears in the linearisation of any parent following it, that is, of any parent listed to its left in $layers.

A restatement on the weak side of its descendant is retained, since it carries meaning: it constrains where the ancestor falls relative to the def’s other parents (see below). Only restatements that cannot be honoured are dropped. A rule dropping every restatement, whichever side it fell on, was considered and rejected for this reason.

Genuine conflicts are still reported. In Python’s ex_2 hierarchy, where A lists [X, Y] and B lists [Y, X], any layer deriving from both A and B still fails with “Inconsistent hierarchy”.

The pedalo example

Figure 2 of Barrett et al. gives this hierarchy, written here as Infusion $layers, weakest first:

fluid.def("boat", {$layers: "fluid.component"});
fluid.def("dayBoat", {$layers: "boat"});
fluid.def("wheelBoat", {$layers: "boat"});
fluid.def("engineless", {$layers: "dayBoat"});
fluid.def("smallMultihull", {$layers: "dayBoat"});
fluid.def("pedalWheelBoat", {$layers: ["wheelBoat", "engineless"]});
fluid.def("smallCatamaran", {$layers: "smallMultihull"});
fluid.def("pedalo", {$layers: ["smallCatamaran", "pedalWheelBoat"]});

dayBoat reaches pedalWheelBoat through engineless, its strongest parent, so dayBoat beats wheelBoat. The paper uses exactly this to motivate monotonicity: under CLOS’s linearisation, pedalo reorders wheelBoat above dayBoat, so a method on dayBoat is the most specific for both of pedalo’s parents, but a method on wheelBoat is the most specific for pedalo itself. C3 forbids this, and so does Infusion.

Suppose the author of pedalWheelBoat restates dayBoat. Strongest first, for comparison with the paper:

pedalWheelBoat $layers Linearisation, strongest first Outcome
["wheelBoat", "engineless"] pedalWheelBoat, engineless, dayBoat, wheelBoat, boat dayBoat beats wheelBoat
["dayBoat", "wheelBoat", "engineless"] pedalWheelBoat, engineless, wheelBoat, dayBoat, boat Restatement kept: wheelBoat beats dayBoat
["wheelBoat", "engineless", "dayBoat"] pedalWheelBoat, engineless, dayBoat, wheelBoat, boat Previously an error, now the restatement is ignored

In each case the linearisation of pedalo extends that of pedalWheelBoat, as C3 requires. With the second variant it is pedalo, pedalWheelBoat, engineless, wheelBoat, smallCatamaran, smallMultihull, dayBoat, boat. The three variants appear as fixtures in FluidJSTests.js.

The second row shows why consistent restatements are retained. Under a rule which dropped them as well, it would collapse into the first row, silently discarding a statement which is consistent and which changes the result.

Consequences of the relaxation

  • Monotonicity is unaffected. A dropped parent’s linearisation is already contained in its descendant’s, so the merge loses no constraint by dropping it. Every layer’s linearisation still extends those of its parents without reordering them.
  • An author can be silently misunderstood. In the third row, the author may have meant either “dayBoat should beat engineless”, which is impossible, or “dayBoat should beat wheelBoat”, which they get. Previously the error showed them that their list could not mean what they wrote. Now they learn it only from the values in the merged component, which is the kind of dispatch surprise the paper set out to rule out, here arising from discarded input rather than from a non-monotonic algorithm.
  • Whether a restatement counts depends on distant layers. If engineless were redefined so that it no longer inherited from dayBoat, the ignored mention would come into effect, making dayBoat the strongest parent of pedalWheelBoat. With live editing of layers, for example in the tldraw workspace, a change to one layer can therefore change precedence in another layer which never mentioned it.
  • Only direct restatements are forgiven. Conflicts arising between the hierarchies of two parents still fail.

Other places where layer names are combined

Every site which assembles a list of layer names follows the same weakest-first convention:

  • Co-occurrences (linkages). The output layers of a $linkage which matches a component are added at the strong end of its layer list. Of two linkages firing in the same round, the one registered later wins.
  • Subcomponent records. When several layers contribute $component records at the same member, their $layers are gathered weakest contribution first. Duplicates are removed by fluid.uniqueLayerNames, which keeps each name’s last, and so strongest, occurrence. Within one record, a layer listed later beats one listed earlier.
  • Merge records. A component’s merge records, such as its creator function’s argument, its subcomponent record and its live layer, contribute layer names weakest record first, after the component’s own layer names. A creator function’s $layers therefore beat the layer of the function itself.
  • Dynamic layers. References in $layers such as "{self}.flavour" are resolved reactively and merged at dynamicLayers priority, above all static layers and literal configuration, wherever they appear in the list. Amongst themselves they follow the same order: a later reference beats an earlier one, the dynamic layers of a derived layer beat those it inherits, and a component’s own record-level references beat inherited ones. Writing dynamic references at the right of a list, as is conventional, keeps the written order consistent with the outcome. A dynamic reference written before a static layer still beats it.
  • Single File Components. The SFC loader’s tag layer fluid.sfcLayer is placed first, at the weak end, of an SFC’s $layers. Appending it would make it an ancestor restated to the right of any SFC deriving from another.
  • Choosing dynamic layers. fluid.definedLayers(...candidates) filters candidates to those which are defined, keeping their order, so callers list the most specific, and strongest, candidate last. fluid.firstDefinedLayer(...candidates) instead takes candidates in order of preference and returns only the first which is defined, so its argument order is unaffected.
  • Transmission. The Portable Transmission Format sorts layer names by derivation rank, which does not depend on list order.

Residual issues

  • Dynamic layers are promoted above all static layers regardless of their position in a list, as described above. A rule honouring their written position would be more uniform. It would change the outcome only of lists which write a dynamic reference before a static layer, since the conventional idiom $layers: ["fluid.component", "{self}.flavour"] already lists it last.
  • Order is a hard constraint only inside a def. At the top level of a component, or within a subcomponent record, it is a preference, so a misordered list there raises no error.
  • The relaxation trades an error for silence in one class of case, as described under Consequences of the relaxation. A diagnostic could be logged when a restatement is ignored, should this prove confusing in practice.