Skip to content

Instantly share code, notes, and snippets.

@NullVoxPopuli
Last active March 14, 2021 22:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save NullVoxPopuli/7e472191b3f5021433b8552158a4379e to your computer and use it in GitHub Desktop.
Save NullVoxPopuli/7e472191b3f5021433b8552158a4379e to your computer and use it in GitHub Desktop.
RFC 712/715: Manually Managing Sticky Query Params
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
export default class ApplicationController extends Controller {
@service router;
@service('queryParams') qpService;
get qps() {
return this.router.currentRoute.queryParams;
}
}
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class ArticlesController extends Controller {
// for RFC 715, this line would be able to be removed
// NOTE: when using `transitionTo(href)`, query params
// do not need to be specified here, to be identified by the router
// _however_, it seems they are _removed_ from the URL (but still
// present on the currentRoute.queryParams object)
//
// For this demo, the queryParams array is commented out to prove that the
// controller is not managing the query param state
//queryParams = ['_category'];
@service router;
@service('queryParams') qpService;
// Read-only Query Param
// Encourages Query Params to only be changed via transition
get category() {
// Because controllers implicity set properies that match entries
// in the queryParams array, we cannot have naming conflicts
// between getters and the Query Params
// (Until RFC 715: https://github.com/emberjs/rfcs/pull/715)
return this.qpService.getQP('_category');
}
get filteredArticles() {
let category = this.category;
let { articles } = this.model;
if (category) {
return articles.filter(article => {
// HackerNews doesn't have categories, but we'll simulate that
// by filtering on type / title
let { type, title } = article;
if (type === category) return true;
return title.toLowerCase().startsWith(category);
});
} else {
return articles;
}
}
}
import { helper } from '@ember/component/helper';
import EmberObject from '@ember/object';
export default helper(function stringify(params/*, hash*/) {
let target = params[0];
// Ember adds a bunch of private properties for legacy support reasons
// Hopefully that can all be removed soon
target = filterOutLegacyEmberCompatibilityKeys({...target});
return JSON.stringify(target, null, 4);
});
const filterOutLegacyEmberCompatibilityKeys = obj => Object
.entries(obj || {})
.reduce((acc, [key, value]) => {
if (key.startsWith('__')) return acc;
acc[key] = value;
return acc;
}, {});
import EmberRouter from '@ember/routing/router';
import config from './config/environment';
const Router = EmberRouter.extend({
location: 'none',
rootURL: config.rootURL
});
Router.map(function() {
this.route('articles');
this.route('about');
});
export default Router;
import Route from '@ember/routing/route';
import { withStickyParams } from 'twiddle/utils';
import { getLatestHackerNews } from 'twiddle/totally-robust-data-layer';
@withStickyParams
export default class Articles extends Route {
async model() {
let stories = await getLatestHackerNews();
return {
articles: stories,
};
}
}
import Service, { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from 'tracked-built-ins';
const STICKY_CACHE = tracked({});
function ensureForUrl(url) {
let existing = STICKY_CACHE[url];
if (!existing) {
STICKY_CACHE[url] = tracked({});
existing = STICKY_CACHE[url];
}
return existing;
}
const getForUrl = (url) => STICKY_CACHE[url];
export default class StickyQueryParamsService extends Service {
@service router;
get cache() {
return STICKY_CACHE;
}
get current() {
let path = this.router.currentURL.split('?')[0];
return getForUrl(path) || {};
}
@action
setFor(url, qps) {
let cacheForUrl = ensureForUrl(url);
for (let qp in qps) {
cacheForUrl[qp] = qps[qp];
}
}
@action
getFor(url) {
return getForUrl(url);
}
@action
getQP(qpName) {
return this.current[qpName];
}
}
body {
margin: 12px 16px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 12pt;
}
h1 {
margin-bottom: 0.5rem;
}
.flex {
display: flex;
}
.gap-4 {
gap: 1rem;
}
This twiddle / example ember app is part of a series for Ember RFCs 712 and 715 to demonstrate various implementations of patterns that folks in the community use.
The goal is to demonstrate that that these RFCs don't prevent any existing behavior, but provide either (hopefully bette) alternatives or move the implementation of into user-space.
<h1>Example for
<a target="_blank" href="https://github.com/emberjs/rfcs/pull/712">RFC 712</a> /
<a target="_blank" href="https://github.com/emberjs/rfcs/pull/715">RFC 715</a>
</h1>
<em>712: Query Params as Derived Data</em>
<br>
<small>
Manually Managing Sticky Params (a question asked in RFC 715, which gets rid of sticky params (behind optional-feature).<br>
As the QPs change, the link hrefs are updated accordingly.
</small>
<pre>
QPs: {{stringify this.qps}}
URL: {{this.router.currentURL}}
Sticky Cache: {{stringify this.qpService.cache}}
</pre>
<br>
<nav class="flex gap-4">
<Link @href="/articles">
Articles
</Link>
<Link @href="/articles?_category=ask">
AskHN
</Link>
<Link @href="/articles?_category=show">
ShowHN
</Link>
<Link @href="/articles?_category=job">
Job
</Link>
<span>|</span>
<Link @href="/about">
About
</Link>
</nav>
<hr>
{{outlet}}
<br>
<br>
{{#each this.filteredArticles as |article|}}
<div>
{{article.score}} | <a target="_blank" href={{article.url}}>{{article.title}}</a>
<br>
<em>by {{article.by}}</em>
<hr>
</div>
{{/each}}
export async function getLatestHackerNews() {
let [ask, show, job] = await Promise.all([
latestStories('ask'),
latestStories('show'),
latestStories('job'),
]);
let mostRecent = [...ask, ...show, ...job];
// N+1 Query, yay!
let stories = await Promise.all(mostRecent.map(id => getItem(id)));
return stories;
}
async function getItem(id) {
return await get(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);
}
async function get(url) {
let response = await fetch(url);
return await response.json();
}
async function latestStories(type) {
let latest = await get(`https://hacker-news.firebaseio.com/v0/${type}stories.json`);
let mostRecent = latest.slice(0, 10);
return mostRecent;
}
{
"version": "0.17.1",
"EmberENV": {
"FEATURES": {},
"_TEMPLATE_ONLY_GLIMMER_COMPONENTS": true,
"_APPLICATION_TEMPLATE_WRAPPER": true,
"_JQUERY_INTEGRATION": false
},
"options": {
"use_pods": false,
"enable-testing": false
},
"dependencies": {
"ember": "3.18.1",
"ember-template-compiler": "3.18.1",
"ember-testing": "3.18.1"
},
"addons": {
"@glimmer/component": "1.0.0",
"tracked-built-ins": "1.0.2"
}
}
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
/**
Note: this is super hacky, and would be *MUCH MUCH MUCH* easier if RFC 715 is implemented
*/
export function withStickyParams(WrappedRoute) {
class WithStickyParams extends WrappedRoute {
@service router;
@service queryParams;
constructor() {
super(...arguments);
this.router.on('routeWillChange', (transition) => {
this.syncParams(transition);
});
}
async beforeModel(transition) {
this.syncParams(transition);
return super.beforeModel(...arguments);
}
@action
syncParams(transition) {
let { to } = transition;
let { queryParams } = to;
// TODO: we need a better way to get the URL out of a transition object
let toUrl = `/${to.name}`;
let cleanedQPs = filterQPs(queryParams);
let stickyQPs = this.queryParams.getFor(toUrl);
// because the URL must reflect the state query params, we must transition
if (!queryParamsMatch(cleanedQPs, stickyQPs)) {
this.queryParams.setFor(toUrl, cleanedQPs || {});
// NOTE: that with normal (Ember 3.18) sticky query params,
// the query params are not present in the URL, but the
// values exist on the controller
return this.router.transitionTo({ queryParams });
}
}
}
return WithStickyParams;
}
function queryParamsMatch(a, b) {
if (!a && b) return false;
if (!b && a) return false;
if (a === b) return true;
let key = new Set();
Object.keys(a).forEach(k => key.add(k));
Object.keys(b).forEach(k => key.add(k));
// there are some weird things happening o.o
// it would be fantastic to be able to opt in to simpler QP infra (RFC 715)
let keys = [...key.values()].filter(k => isValidKey(k));
for (let key of keys) {
if (a[key] !== b[key]) return false;
}
return true;
}
// sometimes transitions append `routeName:` to the query params
function filterQPs(qps) {
let result = {};
for (let qp in qps) {
if (isValidKey(qp)) {
result[qp] = qps[qp];
}
}
return result;
}
function isValidKey(k) {
return !k.startsWith('__') && !k.includes(':');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment