I am building a Remix app, and wanted to record some user analytics in my database based on what page the user was viewing. I also wanted to do so on a route by route basis, rather than just simply the raw URL.
For example: I wanted to know "user viewed URL /emails/123" as well as "user viewed route /emails/$emailId"
This problem could be generalized as "I want to run a piece of server code once per user navigation"
For my tracking I'm assuming users have javascript enabled in their browser.
Solutions I tried:
Record in the loader
This would be something like:
export const loader: LoaderFunction = async ({ request, params }): Promise<LoaderData> => {
myDb.recordPageVisit(request.url);
}
This doesn't work because the loader can be called multiple times per page visit (eg. after an action is run)
It's possible that there's some value hidden in the request parameter that tells us whether this is an initial page load or if it's a later visit, but if so I couldn't find it, including when I examined the raw HTTP requests.
It's also annoying to have to put this code inside of every loader.
Record the URL in the node code
I'm using #remix-run/node as my base remix server, so I have the escape hatch of setting up node middleware, and I thought that might be a good answer:
const app = express();
app.use((req, res, next) => {
if (req.url.indexOf("_data") == -1) {
myDb.recordPageVisit(req.url);
}
next();
});
I tried ignoring routes with _data in them, but that didn't work because remix is being efficient and when the user navigates, it is using an ajax-y call to only get the loaderData rather than getting the full rendered page from the server. I know this is the behavior of Remix, but I had not remembered it before I went down this path :facepalm:
As far as I can tell it's impossible to stateless-ly track unique pageviews (ie based purely on the current URL) - you need see the user's previous page as well.
I wondered if referer would allow this to work statelessly, but it appears that the referer is not behaving how I'd hoped: the referer header is already set to the current page in the first loader request for the data for the page. So initial load and load-after-mutation appear identical based on referer. I don't know if this is technically a bug, but it's certainly not the behavior I'd expect.
I ended up solving this by doing the pageview tracking in the client. To support recording this in the DB, I implemented a route that just received the POSTs when the location changed.
The documentation for react-router's useLocation actually includes this exact scenario as an example.
From https://reactrouter.com/docs/en/v6/api#uselocation:
function App() {
let location = useLocation();
React.useEffect(() => {
ga('send', 'pageview');
}, [location]);
return (
// ...
);
}
However, that doesn't quite work in remix - the location value is changed after actions (same text value, but presumably different ref value). So I started saving the last location string seen, and then only report a new pageview when the location string value has changed.
So after adding that stateful tracking of the current location, I landed on:
export default function App() {
// ...other setup omitted...
const [lastLocation, setLastLocation] = useState("");
let location = useLocation();
const matches = useMatches();
useEffect(() => {
if (lastLocation == location.pathname) {
return;
}
// there are multiple matches for parent route + root route, this
// will give us the leaf route
const routeMatch = matches.find((m) => m.pathname == location.pathname);
setLastLocation(location.pathname);
fetch("/api/pageview", {
body: JSON.stringify({
url: location.pathname,
// routeMatch.id looks like: "/routes/email/$emailId"
route: routeMatch?.id }),
method: "POST",
}).then((res) => {
if (res.status != 200) {
console.error("could not report pageview:", res);
}
});
}, [location]);
The matches code is not necessary for tracking just raw URLs, but I wanted to extract the route form (eg /emails/$emailId), and matches.id is a close match to that value - I strip "routes/" serverside. Matches docs: https://remix.run/docs/en/v1/api/remix#usematches
Client side pageview tracking is a bit annoying since clients are flaky, but for the current remix behavior I believe this is the only real option.
Did it a different way for routes, remix is funny cuz of the whole parent route thing * so I use a route logger
Beans.io
https://www.npmjs.com/package/beansio
Related
The Problem
When navigating away from query components that use the state of the app route as required variables, I get GraphQL errors of the sort:
Variable "$analysisId" of required type "ID!" was not provided.
"Navigating away" means, for example, going
from: /analysis/analysis-1/analyse/
to: /user-profile/
Background
I am building an SPA using Apollo GraphQL, and I have some queries which follow this pattern:
query Analyse($analysisId: ID!) {
location #client {
params {
analysisId #export(as: "analysisId")
}
}
analysis(analysisId: $analysisId) {
id
# ... etc
}
}
The location field gets a representation of the SPA router's state. That state is held in an Apollo client "reactive variable". Query components are programmed to not begin subscribing to the query unless that reactive variable exists and has the required content.
shouldSubscribe(): boolean {
return !!(locationVar()?.params?.analysisId);
}
Params represents express-style URL params, so the route path is /analysis/:analysisId/analyse.
If the user navigates to /analysis/analysis-1/analyse, the query component's variables become: { analysisId: "analysis-1" }`. This works fine when loading the component.
What I Think is Happening
When the component connects to the DOM, it checks to see if it's required variables are present in the router state, and if they are, it creates an ObservableQuery and subscribes.
Later, when the user navigates away, the ObservableQuery is still subscribed to updates when suddenly the required analysisId variable, exported by the client field location.params.analysisId is nullified.
I think that since the ObservableQuery is still subscribed, it sends off the query with null analysisId variable, even though it's required.
What I've Tried
By breaking on every method in my query component base class, I'm reasonably sure that the component base class is not at fault - there's no evidence that it is refetching the component when the route changes. Instead, I think this is happening inside the apollo client.
I could perhaps change the schema for the query from analysis(analysisId: ID!): Analysis to analysis(analysisId: ID): Analysis, but that seems roundabout, as I might not have control over the server.
How do I prevent apollo client from trying to fetch a query when it has required variables and they are not present?
This seems to be working fine so far, in my HttpLink, src/apollo/link/http.ts:
import { ApolloLink, from } from '#apollo/client/link/core';
import { HttpLink } from '#apollo/client/link/http';
import { hasAllVariables } from '#apollo-elements/lib/has-all-variables';
const uri =
'GRAPHQL_HOST/graphql';
export const httpLink = from([
new ApolloLink((operation, forward) => {
if (!hasAllVariables(operation))
return;
else
return forward(operation);
}),
new HttpLink({ uri }),
]);
I'm using a vuejs navigation guard to do some checks to check if a user is valid to enter a page. If the user doesn't meet the criteria I want the auth guard to return false which it currently does. However, when this is done the browser url gets set back to the previous url I came from. I don't want this behaviour instead I want the browser url to stay the same but still keep the user unable to use the page.
The reason I want this is because when the user hits refresh I want it to stay on the same page. I know this is intended vuejs behaviour for the router but I'm hoping to find a way around it. Here is the code for auth guard.
function guardRoute (to, from, next) {
if (window.$cookies.get('kiosk_mode') === new DeviceUUID().get()) {
return next(false)
}
return next()
}
To reject a navigation but to not reset the url to its previous state you can reject the navigation with (requires vue 2.4.0+):
next(new Error('Authentication failure'));
And if you don't have router error handling then you need to include:
router.onError(error => {
console.log(error);
});
See documentation for more details: https://router.vuejs.org/guide/advanced/navigation-guards.html#global-before-guards
Try this
history.pushState({}, null, '/test') before the return next(false);
I am developing an application where users create custom playlists from youtube video urls and those playlist are shown at homepage. When a user click on playlist, it is redirected to a page showing videos in that particular playlist.
Technology stack: Laravel 5.8, Vue, MySql, Pusher
What I have tried so far:
Home Page Component (PublicPlaylist.vue) listens to a channel: live-playlist-playing on mounted() event
this.channel = window.Echo.channel(`live-playlist-playing`);
this.channel.listen("LivePlaylistPlaying", e => {
this.object_array.push(e);
var array = this.object_array;
var seenNames = {};
array = array.filter(function(currentObject) {
if (currentObject.playlist_id in seenNames) {
return false;
} else {
seenNames[currentObject.playlist_id] = true;
return true;
}
});
this.distinct_object = array;
})
whenever another user goes to a playlist page e.g. www.example.com/playlist/1 its component SinglePlaylist.vue calls api to fetch details:
mounted(){
window.axios
.get(
"/api/playlist/"+this.playlist_id,
{
headers: fetchAuthHeaders()
}
)
.then(response => {
this.playlist = response.data.data;
})
.catch(error => {
// if (false == isErrorHandled(error, this)) {
// consoleLog(error);
// }
});
}
Above API method broadcasts an event which is listened by the channel live-playlist-playing, as follows:
public function viewPlaylist($playlistId)
{
/**
some code
*/
broadcast(new LivePlaylistPlaying(Auth::user()->name, $playlist->playlist_name, $playlistId));
return response()->api(true, 'Playlist fetched successfully', $playlist);
}
I hope I have tried to disclose as much as details but you are free to ask for more information; coming back to question, Above code works fine for this case, steps to be taken in consideration.
suppose a user A is on home page and
now user B access the single playlist page
A will get to know a playlist is being accessed by another user.
But what if user B comes first i.e
User B accesses the single playlist page
User A comes on home page
A will not get any information about playlist being played until a user C comes in the picture.
It seems you could benefit from using Presence Channels:
This makes it extremely easy to build chat room and "who's online"
type functionality to your application. Think chat rooms,
collaborators on a document, people viewing the same web page,
competitors in a game, that kind of thing.
You will need to authorise your connections, then you can subscribe to a presence channel. Presence channels broadcast a member_removed or member_added event whenever someone joins or leaves the channel, which you can use to identify when users are on the page regardless of what order the users join the page.
I am currently working on an app that do not support server rendering, so i have to have an empty state when the client loads the app. Then a fetch is dispatched and the state is soon updated.
I have a component (some kind of editor) which should find an object based on a url-parameter. My mapStateToProps function looks something like this:
const mapStateToProps = (state, ownProps) => {
return {
book: state.library.list.find(function(book){ return book.id === ownProps.params.book_id})
}
}
this.props.book is undefined when the component runs getInitialState, so it does not get the update when the state is fetched. I get the following error in the console of my browser when the component loads:
TypeError: undefined is not an object (evaluating 'this.props.book.title')
From there on the editor remains empty, even when the state is received from the server later on.
Any idea of how i can solve this problem?
What you need to do is put a watch when you are loading data from this.props.book to not be an undefined
if(this.props.book) {
//do something with this.props.book
}
Solution: Preload the store on the server
I finally found an answer to my problem. What i did, was to check upon every request if the user was logged in (through JSON web tokens). If the user was logged in, i preloaded the state and sent it with the first request (assigned it to window._PRELOADED_STATE_ with a script tag in the response string).
Then i also added two lines on the client code
const preloadedState = window.__PRELOADED_STATE__
const store = createStore( rootReducer, preloadedState, applyMiddleware(apiMiddleware, thunkMiddleware) );
then the store was updated before the editor component asked for it.
This solution is based on some ideas introduced at redux's home page http://redux.js.org/docs/recipes/ServerRendering.html
I'm writing a front-end to my RESTful API using Backbone... and I'm really enjoying it so far. Learning this framework continues to be super interesting. However, I am now stumped on something that seems like, to me at least, that it should be straight forward.
I now have a single (and only) html page where the main application resides that lists one or more products. And, lets say it resides here: http://localhost/index.html
I would like to be able to switch from the product list view to the new product view (via click event on a button at the top). And that, from what I understand, I need to begin using a router for switching using the pattern described in How to switch views using Backbone.js.
Is view-switching what I need to be doing to achieve this behavior?
This looks hokey: http://localhost/index.html#product/newAnd, since I'm using [tornado](http://tornadoweb.org) as my web server for both my API and static content, I can't just implement a rewrite rule easily. I may switch to using nginx for static content in the near future, but I haven't yet. If I'm to use a router to switch views like when going from Review to Create (of CRUD operations), how do I change the URL/URI to look something more along the lines of thishttp://localhost/product/new
In order to receive hashless url changes, your browser has to support pushstate. If I am not mistaken, Backbone will fallback to using hashes if your browser does not support pushstate. You would initialize your router with the following in order to use pushstate in your application:
Backbone.history.start({pushState: true})
I like #alexanderb's use of view switching. Just MAKE sure when you are changing views, you dispose of them properly. Otherwise you will run into some complex problems that are difficult to debug. Read more here.
Yes, you need 2 things - Router and ApplicationViewManager (some class, that is responsible for changing the view).
define(function () {
var ViewManager = function () {
return {
show: _showView
};
};
function _showView(view) {
if (this.currentView) {
this.currentView.close();
}
this.currentView = view;
this.currentView.render();
$("#app").html(this.currentView.el);
}
return ViewManager;
});
In router, you do something like:
// router
var ApplicationRouter = Backbone.Router.extend({
initialize: function () {
this.viewManager = new ViewManager();
},
routes: {
'': 'dashboard',
'configure/sites/:id': 'configure'
},
dashboard: function () {
var app = require('./apps/DashboardApp');
app.run(this.viewManager);
},
configure: function (id) {
var app = require('./apps/ConfigureApp');
app.run(id, this.viewManager);
}
});
Some code examples, you can take from this repository.