packages/recoil-sync/RecoilSync_URL.js (248 lines of code) (raw):

/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @emails oncall+recoil * @flow strict-local * @format */ 'use strict'; import type { ItemKey, ItemSnapshot, ReadItem, StoreKey, SyncEffectOptions, } from './RecoilSync'; import type {AtomEffect} from 'Recoil'; import type {CheckerReturnType} from 'refine'; const {DefaultValue, RecoilLoadable} = require('Recoil'); const {syncEffect, useRecoilSync} = require('./RecoilSync'); const React = require('react'); const {useCallback, useEffect, useMemo, useRef} = require('react'); const err = require('recoil-shared/util/Recoil_err'); const {assertion, mixed, writableDict} = require('refine'); type NodeKey = string; type ItemState = CheckerReturnType<typeof itemStateChecker>; type AtomRegistration = { history: HistoryOption, itemKeys: Set<ItemKey>, }; const registries: Map<StoreKey, Map<NodeKey, AtomRegistration>> = new Map(); const itemStateChecker = writableDict(mixed()); const refineState = assertion(itemStateChecker); const wrapState = (x: mixed): ItemSnapshot => { return new Map(Array.from(Object.entries(refineState(x)))); }; const unwrapState = (state: ItemSnapshot): ItemState => Object.fromEntries( Array.from(state.entries()) // Only serialize atoms in a non-default value state. .filter(([, value]) => !(value instanceof DefaultValue)), ); function parseURL( href: string, loc: LocationOption, deserialize: string => mixed, ): ?ItemSnapshot { const url = new URL(href); switch (loc.part) { case 'href': return wrapState(deserialize(`${url.pathname}${url.search}${url.hash}`)); case 'hash': return url.hash ? wrapState(deserialize(decodeURIComponent(url.hash.substr(1)))) : null; case 'search': return url.search ? wrapState(deserialize(decodeURIComponent(url.search.substr(1)))) : null; case 'queryParams': { const searchParams = new URLSearchParams(url.search); const {param} = loc; if (param != null) { const stateStr = searchParams.get(param); return stateStr != null ? wrapState(deserialize(stateStr)) : null; } return new Map( Array.from(searchParams.entries()).map(([key, value]) => { try { return [key, deserialize(value)]; } catch (error) { return [key, RecoilLoadable.error(error)]; } }), ); } } throw err(`Unknown URL location part: "${loc.part}"`); } function encodeURL( href: string, loc: LocationOption, items: ItemSnapshot, serialize: mixed => string, ): string { const url = new URL(href); switch (loc.part) { case 'href': return serialize(unwrapState(items)); case 'hash': url.hash = encodeURIComponent(serialize(unwrapState(items))); break; case 'search': url.search = encodeURIComponent(serialize(unwrapState(items))); break; case 'queryParams': { const {param} = loc; const searchParams = new URLSearchParams(url.search); if (param != null) { searchParams.set(param, serialize(unwrapState(items))); } else { for (const [itemKey, value] of items.entries()) { value instanceof DefaultValue ? searchParams.delete(itemKey) : searchParams.set(itemKey, serialize(value)); } } url.search = searchParams.toString(); break; } default: throw err(`Unknown URL location part: "${loc.part}"`); } return url.href; } /////////////////////// // useRecoilURLSync() /////////////////////// export type LocationOption = | {part: 'href'} | {part: 'hash'} | {part: 'search'} | {part: 'queryParams', param?: string}; export type BrowserInterface = { replaceURL?: string => void, pushURL?: string => void, getURL?: () => string, listenChangeURL?: (handler: () => void) => () => void, }; export type RecoilURLSyncOptions = { storeKey?: StoreKey, location: LocationOption, serialize: mixed => string, deserialize: string => mixed, browserInterface?: BrowserInterface, }; const DEFAULT_BROWSER_INTERFACE = { replaceURL: url => history.replaceState(null, '', url), pushURL: url => history.pushState(null, '', url), getURL: () => window.document.location, listenChangeURL: handleUpdate => { window.addEventListener('popstate', handleUpdate); return () => window.removeEventListener('popstate', handleUpdate); }, }; function useRecoilURLSync({ storeKey, location: loc, serialize, deserialize, browserInterface, }: RecoilURLSyncOptions): void { const {getURL, replaceURL, pushURL, listenChangeURL} = { ...DEFAULT_BROWSER_INTERFACE, ...(browserInterface ?? {}), }; // Parse and cache the current state from the URL // Update cached URL parsing if properties of location prop change, but not // based on just the object reference itself. const memoizedLoc = useMemo( () => loc, // Complications with disjoint uniont // $FlowIssue[prop-missing] [loc.part, loc.queryParam], // eslint-disable-line fb-www/react-hooks-deps ); const updateCachedState: () => void = useCallback(() => { cachedState.current = parseURL(getURL(), memoizedLoc, deserialize); }, [getURL, memoizedLoc, deserialize]); const cachedState = useRef<?ItemSnapshot>(null); // Avoid executing updateCachedState() on each render const firstRender = useRef(true); firstRender.current && updateCachedState(); firstRender.current = false; useEffect(updateCachedState, [updateCachedState]); const write = useCallback( ({diff, allItems}) => { updateCachedState(); // Just to be safe... // This could be optimized with an itemKey-based registery if necessary to avoid // atom traversal. const atomRegistry = registries.get(storeKey); const itemsToPush = atomRegistry != null ? new Set( Array.from(atomRegistry) .filter( ([, {history, itemKeys}]) => history === 'push' && Array.from(itemKeys).some(key => diff.has(key)), ) .map(([, {itemKeys}]) => itemKeys) .reduce( (itemKeys, keys) => itemKeys.concat(Array.from(keys)), [], ), ) : null; if (itemsToPush?.size && cachedState.current != null) { const replaceItems: ItemSnapshot = cachedState.current; // First, repalce the URL with any atoms that replace the URL history for (const [key, value] of allItems) { if (!itemsToPush.has(key)) { replaceItems.set(key, value); } } replaceURL(encodeURL(getURL(), loc, replaceItems, serialize)); // Next, push the URL with any atoms that caused a new URL history entry pushURL(encodeURL(getURL(), loc, allItems, serialize)); } else { // Just replace the URL with the new state replaceURL(encodeURL(getURL(), loc, allItems, serialize)); } cachedState.current = allItems; }, [getURL, loc, pushURL, replaceURL, serialize, storeKey, updateCachedState], ); const read: ReadItem = useCallback(itemKey => { return cachedState.current?.has(itemKey) ? cachedState.current?.get(itemKey) : new DefaultValue(); }, []); const listen = useCallback( ({updateAllKnownItems}) => { function handleUpdate() { updateCachedState(); if (cachedState.current != null) { updateAllKnownItems(cachedState.current); } } return listenChangeURL(handleUpdate); }, [listenChangeURL, updateCachedState], ); useRecoilSync({storeKey, read, write, listen}); } function RecoilURLSync(props: RecoilURLSyncOptions): React.Node { useRecoilURLSync(props); return null; } /////////////////////// // urlSyncEffect() /////////////////////// type HistoryOption = 'push' | 'replace'; function urlSyncEffect<T>({ history = 'replace', ...options }: { ...SyncEffectOptions<T>, history?: HistoryOption, }): AtomEffect<T> { const atomEffect = syncEffect<T>(options); return effectArgs => { // Register URL sync options if (!registries.has(options.storeKey)) { registries.set(options.storeKey, new Map()); } const atomRegistry = registries.get(options.storeKey); if (atomRegistry == null) { throw err('Error with atom registration'); } atomRegistry.set(effectArgs.node.key, { history, itemKeys: new Set([options.itemKey ?? effectArgs.node.key]), }); // Wrap syncEffect() atom effect const cleanup = atomEffect(effectArgs); // Cleanup atom option registration return () => { atomRegistry.delete(effectArgs.node.key); cleanup?.(); }; }; } module.exports = { useRecoilURLSync, RecoilURLSync, urlSyncEffect, };