Cycle Js - Approach to Synchronizing History with State?

217 Views Asked by At

I’m trying to synchronize browser history with state contained within an onionify state store in a very simple cycle js example application. I have no problem mapping from state store to history, the issue is in reducing history.state back into the state store. Essentially, I get caught in an infinity loop of state streams flowing into one another, this crashes the browser.

import { run } from "@cycle/run";
import { div, button, p, makeDOMDriver } from "@cycle/dom";
import onionify from "cycle-onionify";
import { makeHistoryDriver } from "@cycle/history";
import xs from "xstream";

const main = (sources) => {
  const popHistory$ = sources.history; // This is the problem...

  const action$ = xs.merge(
    sources.DOM.select(".decrement").events("click").map( () => -1 ),
    sources.DOM.select(".increment").events("click").map( () => +1 ),
  );

  const state$ = sources.onion.state$;

  const vdom$ = state$.map( state =>
    div([
      button(".decrement", "Decrement"),
      button(".increment", "Increment"),
      p(`Count: ${state.count}`)
    ])
  );

  const initReducer$ = xs.of( function initReducer() {
    return {count: 0};
  });

  const updateReducer$ = action$.map( x => function reducer(prevState) {
    return {count: prevState.count + x};
  });
  // this is where the inifinity loop starts
  const historyReducer$ = popHistory$.map( history => function(prevState) {
    return {count: history.state.count};
  });
  // You can't merge historyReducer$ here
  const reducer$ = xs.merge(initReducer$, updateReducer$, historyReducer$);

  const pushHistory$ = state$.map( state => ({
    type: "push",
    pathname: `/?count=${state.count}`,
    state: state
  }));

  return {
    DOM: vdom$,
    onion: reducer$,
    history: pushHistory$,
    debug: popHistory$
  }
};

const onionMain = onionify(main);

run(onionMain, {
  DOM: makeDOMDriver("#main"),
  history: makeHistoryDriver(),
  debug: $ => $.subscribe({next: console.log})
});

I guess my question is: is there an easier way to do this?. Is there an operator that helps here? I feel like what I'm trying to do is fundamentally impossible. Any answers or links to useful resources would be appreciated.

2

There are 2 best solutions below

0
On

Considering I figured this out, I thought I may as well post a minimal working example of routing with history / state with the onion state store. It may be useful for someone in the future as a reference. The answer was to filter out data flowing back to history from history popstates.

import { run } from "@cycle/run";
import { div, button, p, makeDOMDriver } from "@cycle/dom";
import { makeHistoryDriver } from "@cycle/history";
import onionify from "cycle-onionify";
import xs from "xstream";

const main = (sources) => {
  const {
    DOM: domSource$,
    history: historySource$,
    onion: onionSource
  } = sources;

  const action$ = xs.merge(
    domSource$.select(".decrement").events("click")
      .mapTo( state => ({...state, count: state.count - 1, avoidHist: false}) ),

    domSource$.select(".increment").events("click")
      .mapTo( state => ({...state, count: state.count + 1, avoidHist: false}) ),

    historySource$.filter( e => e.state !== undefined ) // essentially captures forward and back button events
           .map( e => e.state.count )
           .map( count => state => ({...state, count, avoidHist: true}) ),

    historySource$.filter( e => e.state === undefined && e.hash !== "" ) // capture hash change events and grab state data with regex
           .map( e => e.hash )
           .map( hashStr => hashStr.match(/count=(-?\d{1,16})/) )
           .filter( val => val !== null )
           .map( arr => arr[1] )
           .map( str => Number(str) )
           .map( count => state => ({...state, count, avoidHist: true}) ) // add state property for filtering history
  );

  const state$ = onionSource.state$;
  const initReducer$ = xs.of( () => ({count: 0}) );
  const onionSink$ = xs.merge(initReducer$, action$);

  const domSink$ = state$.map( state =>
    div([
      button(".decrement", "Decrement"),
      button(".increment", "Increment"),
      p(`Count: ${state.count}`)
    ])
  );

  const historySink$ = state$.filter( state => state.avoidHist !== true ) // filter for avoid history property
    .map( state => ({
    type: "push",
    pathname: `#count=${state.count}`,
    state
  }));

  return {
    DOM: domSink$,
    history: historySink$,
    onion: onionSink$
  };
};

const onionMain = onionify(main);

run(onionMain, {
  DOM: makeDOMDriver("#main"),
  history: makeHistoryDriver()
});

I guess the key with creating application behaviour that mimics the way server rendered webpages interact with history is that non of the mappings from history popstate flow back into history. Seems obvious now, but it took me a while to reason it out.

0
On

From what I can understand, I believe your are just missing a single dropRepeats on the stream that feeds the history driver.

This is not a piece of code I have tested but I believe replacing this line

const pushHistory$ = state$.map(...)

with

 import dropRepeats from 'xstream/extra/dropRepeats'

 // ....

 const pushHistory$ = state$.compose(dropRepeats()).map(...)

Will fix your problem.

This will filter pushState when the state hasn't really changed but will keep the URL in sync with what is in the state and it doesn't require you to change all the event listeners as you did in your previous answer :)

There are some interesting operators in the extras of xstream https://github.com/staltz/xstream/blob/master/EXTRA_DOCS.md