Meteor-Community-Packages/meteor-fast-render

how to use FR with React's hook useTracker, and avoid Flash Of Content ?

Closed this issue · 9 comments

Hello !
First of all, thanks for your work and efforts for maintaining this library

I'm working on a project where i don't want to use (if possible) any additional state manager (like redux) but only hooks provided by meteor.

I'm using Fast Render for SSR, which is great for that !

The issue is precisly with data : I don't know how to make it possible for useTracker to hydrate data coming from FR at first, and then switch to nominal behavior.

So let's say I want to display a list of 500 products. Current behavior is :

  • display SSR content on client. 500 products are displayed.
  • useTracker launch subscription & queries. 0 products are displayed => FOUC
  • products are gradually received on the wire via DDP => List is updated chunck by chunck
  • Product List is finally fully displayed

Expected behavior would be :

  • display SSR content on client. 500 products are displayed.
  • useTracker is used for the first time, so it gets cached data from FR => Product List keeps being fully displayed
  • useTracker synchronises via DDP
  • Product List is up to date

I know that my question is more depending on react-meteor-data package than FR, but I'm sure that since your activly working on it, you have already think about it, and surely have a recipe, an opinion or an advice.

Thanks for your help !

Hi again !

I've just created a repo with simple use case reproduction.

It's here : https://github.com/liitfr/test-fast-render

It's just a meteor new with react template, where I use fast-render & populate Links collection with 500 lorem links.

When you run it you'll see the typical react warning : react_devtools_backend.js:2560 Warning: Did not expect server HTML to contain a <li> in <ul>.

Thanks again

OK,
So I think I come with a solution that i've published in previous repository.
I wrapped default useTracker with a custom one :

import { FastRender } from "meteor/communitypackages:fast-render";
import { useTracker as useDefaultTracker } from "meteor/react-meteor-data";
import { useState } from "react";

const DURATION_BEFORE_SWITCH = 1000;

const DATASOURCE_CLIENT = "CLIENT";
const DATASOURCE_SERVER = "SERVER";

const useTracker = (id, ...args) => {
  // On server, add Extra data
  if (Meteor.isServer) {
    const data = useDefaultTracker(...args);
    FastRender.addExtraData(id, data);
    return { result: data };
  } else {
    // On client

    // start time that will be saved on first page render
    const [startTime, setStartTime] = useState({});
    // server data served by Fast Render
    const [serverData, setServerData] = useState({});

    // Check if Fast Render has extra Data
    const extraData = FastRender.getExtraData(id);

    // Check if its a normal scenario (no Fast Render, only client Tracker data)
    if (extraData == null && startTime[id] == null && serverData[id] == null) {
      return {
        result: useDefaultTracker(...args),
        additionalData: null,
        mainDataSource: DATASOURCE_CLIENT,
        TrackerId: id,
      };
    } else {
      // Scenario if Fast Render served Server data first

      // Save extra Data if any, and save start time
      if (extraData) {
        setServerData({ ...serverData, [id]: extraData });
        setStartTime({ ...startTime, [id]: new Date() });
      }

      // Return Server data first
      if (
        startTime[id] == null ||
        new Date() - startTime[id] < DURATION_BEFORE_SWITCH
      ) {
        return {
          result: serverData[id],
          additionalData: useDefaultTracker(...args),
          mainDataSource: DATASOURCE_SERVER,
          TrackerId: id,
        };
      } else {
        // Then return normal tracker data
        return {
          result: useDefaultTracker(...args),
          additionalData: serverData[id],
          mainDataSource: DATASOURCE_CLIENT,
          TrackerId: id,
        };
      }
    }
  }
};

export { useTracker };

breaking changes :

  • with that behavior, each tracker has to have its own id
  • tracker results are wrapped in a result object
  const results = useTracker("linksTracker", () => LinksCollection.find().fetch())
  const { result: items } = results;

Three other considerations :

  1. I had to use addExtraData for each tracker, which increases payload size. I would like to be able to disable collectionData because I don't use it. It would allow to reduce ssr payload size.
  2. FastRender data are put in react state, so maybe it could be a performance issue ...
  3. With this solution, client still launches queries, but silently, in background

How does it work ?
Well, this custom useTracker first returns FR data related to tracker's Key. It also starts the typical Tracker behavior in background. After a fixed duration (DURATION_BEFORE_SWITCH), this tracker doesn't return FR data but typical Tracker's ones.

You can check code on my repo.
Do you have any feedbacks on this ?

Thanks again.
Mathias

So I've actually never come across this issue before. Are you seeing this issue if you use subscriptions and subscribe to more reasonably sized payloads?

The solution you posted above looks interesting, but on slower connections seems to be flawed. If I throttle my connection in devtools and load the page, it appears to load with ~2 results and then load in the rest a few seconds later. Unfortunately I can't delve deeply into this as I'm quite busy at the moment.

If you absolutely need such large amounts of data sent to the client, I'd still recommend using a subscription. That way you have a signal (subscription.ready()) to know when the data has all loaded. When the page first loads, grab the result of the Colleciton.find().fetch() and store it in a React ref. Only return that result until the subscription is ready. Once the subscription is ready, then return the new result of collection.find.fetch().

dear @copleykj
thanks for your feedback. Here are mine :

  • I can confirm that this issue is already visible on boilerplate dataset, even with a few number of entries. (you can have a look on "test" branch of my repo, where i've just inserted 4 rows)
2021-06-16.12-55-10.mp4
  • I can't reproduce your issue with a slow 3G connexion throttle. What was your setup ? Anyway, I guess it can be related to const DURATION_BEFORE_SWITCH = 1000;.

  • your solution with subscription.ready() & React ref looks much more simple & clear, will definitly try it :) how could I not think of it 😅 before doing this custom tracker. I'll be back here with an example.

But I think there's a real topic about Fast Render returning data collection based or tracker based, isn't it ?

new version of custom tracker, following your suggestions 👍

import { FastRender } from "meteor/communitypackages:fast-render";
import { useRef } from "react";
import { useTracker as useDefaultTracker } from "meteor/react-meteor-data";

const useTracker = ({ trackerId, pubId, pubOpts, query }, deps) => {
  // On Server, add Extra data
  if (Meteor.isServer) {
    const data = useDefaultTracker(query);
    FastRender.addExtraData(trackerId, data);
    // set isReady to true by default
    return { isReady: true, data };
  }
  // On Client

  // Record here data coming from server
  // /!\ We have to define it here and not later because of react hooks sequence that shouldn't change
  const extraData = useRef();

  // Check if subscription is ready
  const isReady = useDefaultTracker(
    () => Meteor.subscribe(pubId, pubOpts).ready(),
    [pubId, pubOpts]
  );

  // Define client tracker, which is the target
  // /!\ We have to define it here and not later because of react hooks sequence that shouldn't change
  const clientTracker = useDefaultTracker(query, [...(deps || []), trackerId]);

  // If subscription is ready, return client tracker
  if (isReady) {
    return { isReady, data: clientTracker };
  }

  // If client sub not ready, check if extra data exists in Fast Render
  const dump = FastRender.getExtraData(trackerId);
  if (dump) {
    extraData.current = { ...extraData.current, [trackerId]: dump };
  }

  // We have extra data and sub is not ready, let's use it !
  if (extraData.current[trackerId]) {
    return {
      isReady: true,
      data: extraData.current[trackerId],
    };
  }

  // At the end, send default result
  return { isReady, data: clientTracker };
};

export { useTracker };

and you can use it like this :

const { isReady, data: items } = useTracker({
  trackerId: "linksTracker",
  pubId: "allLinks",
  query: () => LinksCollection.find().fetch(),
})

code available on this v2 branch

I took a much deeper look at this. When I would load the page the console.log of the items in Info.jsx was showing an empty array initially and then would show more after the data finished loading via ddp. This was definitely puzzling because the way fast-render works, the data should never be empty on page load unless it's not injected into the html payload and that actually looks like exactly what is happening. There seems to be an issue with another package that causes the null publications not to be added to the payload. If I just add a named subscription that returns a cursor of the LinksCollection, and subscribe to it from client code, then everything works exactly as expected when the page first loads.

The issue seems to be with communitypackages:picker. I'll be consulting with @StorytellerCZ on potential causes since he know that packages architecture better than I do, and hopefully we can get a fix out soon. I'll update here as things progress.

So this turned out to be a bug with how this package was using picker, and not actually in picker. I've fixed it and pushed out a new release which fixes the issue in your test repo. Let me know if you have any further issues.

Thank you @copleykj :)

@liitfr You are very welcome of course.