Last active
March 14, 2021 22:24
-
-
Save NullVoxPopuli/7e472191b3f5021433b8552158a4379e to your computer and use it in GitHub Desktop.
RFC 712/715: Manually Managing Sticky Query Params
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Component from '@glimmer/component'; | |
import { action } from '@ember/object'; | |
import { inject as service } from '@ember/service'; | |
// Custom Link Component used because LinkTo | |
// doesn't let us specify URLs / hrefs | |
// | |
// This is also the only way for us to Link to Query Params | |
// not specified on controllers | |
// | |
// <a> tags are supposed to have this implemented though | |
// See: | |
// https://github.com/emberjs/ember.js/pull/19271 | |
// https://github.com/emberjs/rfcs/pull/391 | |
export default class Link extends Component { | |
@service router; | |
@service queryParams; | |
get href() { | |
let passed = this.args.href; | |
let targetInfo = new URL(`${FAKE_ORIGIN}${passed}`); | |
let { search, pathname } = targetInfo; | |
// merge requested QPs with Sticky QPs | |
let qps = this.queryParams.cache[pathname]; | |
let nextQps = mergeQPs(qps, search); | |
if (nextQps) { | |
return `${pathname}?${nextQps}`; | |
} | |
return pathname; | |
} | |
@action | |
navigate(e) { | |
e.preventDefault(); | |
this.router.transitionTo(this.href); | |
} | |
} | |
// URL requires a full valid URI | |
const FAKE_ORIGIN = 'https://fake.origin'; | |
function mergeQPs(a, b) { | |
let targetQps = new URLSearchParams(a); | |
let nextQps = new URLSearchParams(b); | |
let allQps = {}; | |
for (let [qp, value] of targetQps.entries()) { | |
allQps[qp] = value; | |
} | |
for (let [qp, value] of nextQps.entries()) { | |
allQps[qp] = value; | |
} | |
return new URLSearchParams(allQps).toString(); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}, {}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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" | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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