foam3

Memento & Routing

How FOAM syncs UI state (current menu, route, filters, tab selection, …) into the browser URL hash, so it survives refresh and is bookmarkable/back-forward navigable. Two pieces: foam.u2.memento.Memento (the encode/decode engine) and foam.u2.Router (a controller mixin built on top of it for route-driven view switching).


Mental model

A Memento is a node in a chain, one per level of UI that wants to contribute state to the URL. Each node:

The doc comment on the class (src/foam/u2/memento/Memento.js) states the whole contract:

A hierarchical implementation of the Memento pattern. Used to encode UI states/routes in a string form that can be stored and later reverted to. […] Memorable properties are automatically encoded-into / extracted-from the string form of the memento and a sub-Memento, with the used bindings removed, is exported for children to consume.

So a page with a top-level menu selector, and a sub-view with its own tab state, produces one hash string that both levels contribute to and both levels parse independently — neither needs to know about the other’s keys.


String format

#path1/path2/...pathn?key1=value1&key2=value2...&keyn=valuen

which the doc comment calls shorthand for:

#route=path1&route=path2&route=path3&key1=value1&key2=value2...&keyn=valuen

Two things follow directly from that:

The route special case

If a property is named (or shortName‘d) route, it is encoded as part of the memento’s path, not as a key=value parameter:

If a Property is named ‘route’, it will be encoded as part of the memento path, rather than as a keyed parameter.

Concretely: a memorable route property produces #myroute instead of #?route=myroute, and a chain of nested routers produces #admin.users/42 instead of #?route=admin.users&route=42. Every other memorable property is otherwise handled identically to a route property — two-way bound, chained via parent/tail, decoded from the hash — the only difference is where it lands in the string.


Encoding — encode() / toString()

encode(opt_untilObj) walks this Memento’s tail first (recursively), then layers this level’s own memorable properties on top:

this.props.forEach(p => {
  var val = this.obj[p.name] === undefined ? '' : this.obj[p.name];

  if ( p.name === 'route' || p.shortName === 'route' ) {
    if ( ret.route ) ret.route = '/' + ret.route;
    ret.route = encodeURIComponent(val) + ret.route;
  } else {
    // ...ordinary key=value param, only emitted if non-default
    // or if a descendant already bound the same name (ret.bound[name])
  }
});

A non-route property is only written into params if it’s non-default, or if a descendant Memento already bound the same name — the bound map exists specifically to avoid ambiguity about which level a binding belongs to when defaults would otherwise hide it.

toString(encoded) then just concatenates: route + (? + params if any).

encode() is also where usedStr (and the child’s tailStr) get (re)computed as a side effect, when called with no opt_untilObj.


Decoding — the str setter

Setting str is how a Memento is fed a hash string (from the browser, or from a parent’s leftover tailStr). The postSet:

  1. addRouteKeys(s) first splits the path portion (before ?) on / and rewrites each segment back into a synthetic route=segment binding, so the rest of the pipeline only ever deals with ordinary key=value pairs.
  2. createBindings(s) splits the remaining string on & (careful not to split inside {...}, reserved for nested mementos) into [key, value] pairs.
  3. For each of this.props (this level’s memorable properties), the matching binding is consumed out of the array and assigned onto this.obj[p.name], decoded via the property’s own fromString if it has one. Even an absent binding is “set” — to revert the property to its default.
  4. Whatever bindings are left over (not owned by this level) are re-encoded and stored in tailStr, which a tail Memento (if attached) consumes via its own str setter.

Because path segments become route= bindings before the generic parser runs, route really is handled identically to any other memorable property from that point on — matched by key, decoded, assigned — the special-casing is entirely in addRouteKeys() on the way in and the ret.route branch of encode() on the way out.


Making an object memorable

Two pieces:

factory: function() {
  return this.parentMemento_ ?
    this.Memento.create({obj: this, parent: this.parentMemento_}, this) :
    this.WindowHashMemento.create({obj: this}, this);
}

It imports an optional memento_ from its own parent context (aliased parentMemento_) and exports its own memento_ back down, which is how the parent/tail chain wires itself up automatically through ordinary FOAM context nesting — no manual parent: assignment needed by application code. If there’s no parent Memento in context, it becomes the root and creates a WindowHashMemento instead of a plain Memento.

mixins: [ 'foam.u2.memento.Memorable' ],
properties: [
  { name: 'myState', memorable: true }
]

is the complete recipe (also stated directly in the class doc comment).


WindowHashMemento — binding to the browser

foam.u2.memento.WindowHashMemento extends Memento and is the concrete class that should sit at the root of the chain — Memorable’s factory picks it automatically when there’s no parent.

Application code essentially never talks to WindowHashMemento directly — it’s created implicitly by the top-level Memorable (typically ApplicationController, see below) and everything downstream chains off it through context.


foam.u2.Router — routing on top of Memento

foam.u2.Router (src/foam/u2/Router.js) is a Controller mixin that bundles Memorable with breadcrumb and stack coordination, for the common case of “one memorable route property switches between sub-views”:

foam.CLASS({
  package: 'foam.u2',
  name: 'Router',
  extends: 'foam.u2.Controller',
  implements: [ 'foam.u2.Routable' ],
  mixins: [ 'foam.u2.memento.Memorable' ],
  exports: [ 'route' ],
  properties: [
    { name: 'route', memorable: true, transient: true },
    { name: 'routingFeedback_', value: false, transient: true }
  ],
  ...
});

route is exported (exports: [ 'route' ]), so a controller mixing in Router publishes its own current route to children via context — that’s how a child view can read (or memorable-bind) the route its containing router set.

A second, simpler Router definition exists

src/foam/u2/Element2.js defines a second foam.CLASS for foam.u2.Router — same package, same name, but a bare mixins: [ 'foam.u2.memento.Memorable' ] plus a plain memorable route property, without addCrumb/routeChange/routingFeedback_:

foam.CLASS({
  package: 'foam.u2',
  name: 'Router',
  extends: 'foam.u2.Controller',
  mixins: [ 'foam.u2.memento.Memorable' ],
  exports: [ 'route' ],
  properties: [ { name: 'route', memorable: true } ]
});

src/foam/u2/pom.js loads Element2 before Router, so the fuller Router.js definition is the one registered last for the foam.u2.Router name (unverified: FOAM’s exact last-registration-wins semantics for a repeated foam.CLASS call were not traced further here — treat this as the likely-effective definition, not a proven one). If breadcrumb/stack behaviour you expect from Router.js seems to be missing, this duplicate definition is the first place to look.


Real-world usage


Gotchas


See Also