diff --git a/fixtures/frame/pages/[[...all]].js b/fixtures/frame/pages/[[...all]].js index 5a265b127824a..92ccf369913fd 100644 --- a/fixtures/frame/pages/[[...all]].js +++ b/fixtures/frame/pages/[[...all]].js @@ -3,6 +3,9 @@ import App from '../src/App'; export default function Root() { const router = useRouter(); + if (router.asPath === '/[[...all]]') { + return null; + } // We're gonna do our own routing. - return ; + return ; } diff --git a/fixtures/frame/src/App.js b/fixtures/frame/src/App.js index fab05e09aeb45..d80c309b45f99 100644 --- a/fixtures/frame/src/App.js +++ b/fixtures/frame/src/App.js @@ -2,62 +2,73 @@ import {createContext, useState, useEffect, useContext} from 'react'; // --- Product (Server) --- -// Possible values. -// We need a list because they need to be addressable from the client. -// This could be generated based on a convention, e.g. based on a filesystem. -const EntryPoints = { - Shell, - Feed, - Profile, - ProfileTabs, -}; - -export default function App({segments}) { +export default function Root({url}) { return ( - - - + + + ); } -function Shell({path}) { - // TODO: a way to parse parameters by name? - // Currently each Frame gets depth++ so we "consume" URL segments one by one. - const page = path.segments[path.depth]; - let pageContent; - switch (page) { - case undefined: - pageContent = ; +// Extracts data from a URL section. +// Needs to be able to re-run in isolation. +function parseAppParams(reader) { + const path = reader.consume(); + switch (path) { + case '': + return {tab: 'feed'}; + case 'profile': + const id = reader.consume(); + if (id === '') { + throw new Error('Not found'); + } + return {tab: 'profile', id}; + default: + throw new Error('Not found: ' + path); + } +} + +function App() { + const {params} = useContext(FrameContext); + let content; + // Read from the params we just parsed. + switch (params[0].tab) { + case 'feed': + content = ; break; case 'profile': - pageContent = ; + const id = params[0].id; + content = ; break; default: - throw new Error('Not found: ' + page); + throw Error('Unknown'); } return ( <> - - {pageContent} + +
+ {content} ); } -function ShellTabBar() { +function AppTabBar({params}) { + // Note we determine the tab status manually rather than + // letting the links do it. This let us be more accurate, e.g. + // Profile isn't a part of Feed just because its URL is below /. return ( <> - + Feed {' '} - + Dan's Profile {' '} - + Seb's Profile @@ -68,37 +79,66 @@ function Feed() { return

Feed

; } -function Profile({path}) { - const id = path.segments[path.depth]; +function Profile({id}) { return ( -
-

Profile for {id}

+ <> +

Profile {id}

-
+ ); } -function ProfileAbout({id}) { - return

About {id}

; +// Extracts the params for an inner tab. +// This, also, needs to be able to re-run in isolation. +function parseProfileTabsParams(reader) { + const path = reader.consume(); + switch (path) { + case '': + return {tab: 'about'}; + case 'photos': + return {tab: 'photos'}; + default: + throw new Error('Not found: ' + path); + } } -function ProfilePhotos({id}) { - return

Photos of {id}

; +function ProfileTabs() { + const {params} = useContext(FrameContext); + // Each consecutive parent is next in the params chain. + // So params[0] is current, params[1] is parent, etc. + const id = params[1].id; + let content; + switch (params[0].tab) { + case 'about': + content = ; + break; + case 'photos': + content = ; + break; + default: + throw Error('Unknown'); + } + return ( + <> + +
+ {content} + + ); } -function ProfileTabBar({id}) { +function ProfileTabBar({id, params}) { return ( <> - {/* TODO: Relative links? */} - + About {' '} - + Photos

- Go to Dan's Photos + Go to Dan's Photos

@@ -106,132 +146,183 @@ function ProfileTabBar({id}) { ); } -function ProfileTabs({path}) { - const tab = path.segments[path.depth]; - // TODO: Reading an unnamed segment above is awkward. - const id = path.segments[path.depth - 1]; - let tabContent; - switch (tab) { - case 'about': - tabContent = ; - break; - case 'photos': - tabContent = ; - break; - default: - throw new Error('Not found: ' + tab); - } +function ProfileAbout({id}) { return ( <> - - {tabContent} +

About {id}

); } +function ProfilePhotos({id}) { + return

Photos of {id}

; +} + +// Addressable entry points. +// These can be generated with a file convention. +const EntryPoints = { + App: [App, parseAppParams], + ProfileTabs: [ProfileTabs, parseProfileTabsParams], +}; + // --- Infra (Server) --- -const FrameContext = createContext(null); +const UrlContext = createContext(null); +const defaultFrameContext = { + // Child-first frame path + // ["ProfileTabs", "App"]: + cursor: [], + // Child-first parsed params + // [{tab: "about"}, {tab: "profile", id: 3}] + params: [], + // How many URL characters have we consumed so far. + read: 0, +}; +const FrameContext = createContext(defaultFrameContext); -function Link({to, children, plainWhenActive}) { - const {matchRoute, segments} = useContext(FrameContext); - // A match contains metadata we want to pass to the client. - // It is essentially the target frame + what to render in it. - const match = matchRoute(to); - const isActive = to.join('/') === segments.join('/'); +function Link({to, active, children}) { + const context = useContext(FrameContext); + let {target, cursor} = context.match(removeSlashes(to)); + // Prove the data we give to the client Link is serializable. + target = JSON.parse(JSON.stringify(target)); + cursor = JSON.parse(JSON.stringify(cursor)); return ( - + {children} ); } +// A Frame represents a server route handler. +// It provides server context to the Links and Frames below +// so that they can determine which Frame handles a URL. function Frame({entry}) { + const url = removeSlashes(useContext(UrlContext)); + const parentContext = useContext(FrameContext); + const cursor = parentContext.cursor; + const [childContext, target] = getFrameContext(url, parentContext, entry); return ( - - {(target, match) => ( - // Render prop because during the subsequent navigations, - // we'll only render the context and jump over the content. - - )} - + + + {/* Preloaded content for this frame */} + + + ); } -function ReplayableFrame({entry, children}) { - // Get by name from the map. - const Match = EntryPoints[entry]; - const {segments, parents, matchRoute: matchParent} = useContext(FrameContext); - const depth = parents.length; - const path = {segments, depth}; - // This is this frame's keypath. - const target = segments.slice(0, depth).join(':'); - const nextParents = [...parents, entry]; +// An EntryPoint formalizes a server/client boundary. +// It proves that we can reset and replay contexts for +// subsequent navigations with only serializable data. +function EntryPoint({url, cursor}) { + url = removeSlashes(url); + const entry = cursor[0]; + const [Component] = EntryPoints[entry]; return ( - - - - ), - }; - }, - }}> - {children( - target, - // Though unnecessary for a drill-down render, in our client-only - // prototype we replay server contexts here too so that the client - // state always line up and we don't lose it on first navigation. - - - - )} - + + + + + ); } -function ReplayFrames({children, segments, parents}) { - // Restore the parent router stack so Links can talk to it. - let el = children; - parents +function ResetContexts({children}) { + return ( + + + {children} + + + ); +} + +function ReplayContexts({data, children}) { + // Only two things are necessary to replay Frame context + // on the server: the URL and a list of cursor. + const {url, cursor} = JSON.parse(data); + let contexts = [ + { + cursor: [], + params: [], + read: 0, + }, + ]; + cursor .slice() .reverse() .forEach(entry => { - const inner = el; - el = {() => inner}; + // Use the list of cursor to regenerate Frame contexts. + const parentContext = contexts[contexts.length - 1]; + const [childContext] = getFrameContext(url, parentContext, entry); + contexts.push(childContext); }); return ( - - {el} - + + {contexts.reduceRight( + (acc, value) => ( + {acc} + ), + // At the very bottom, place the original children. + children + )} + ); } +function getFrameContext(url, parentContext, entry) { + const consumedUrl = removeSlashes(url.slice(0, parentContext.read)); + const remainingUrl = removeSlashes(url.slice(parentContext.read)); + const [Component, parseParams] = EntryPoints[entry]; + // The client Frame will respond to navigations with this identifier: + const target = consumedUrl.replace(/\//g, ':'); + // Call the parser function with a helper that lets us consume URLs: + const reader = createUrlReader(remainingUrl); + let params = parseParams(reader); + let childContext = { + cursor: [entry, ...parentContext.cursor], + params: [params, ...parentContext.params], + // The next Frame will handle the remaining part of the URL. + read: parentContext.read + reader.getReadLength(), + match(to) { + const canHandle = ensureSlashes(to).startsWith( + ensureSlashes(consumedUrl) + ); + if (!canHandle) { + // Delegate to the parent Frame. + return parentContext.match(to); + } + return { + target, + cursor: childContext.cursor, + }; + }, + }; + return [childContext, target]; +} + +// Helper to consume URL by parts and track how much has been read. +function createUrlReader(url) { + let read = 0; + return { + getReadLength() { + return read; + }, + consume() { + const nextSlash = url.indexOf('/', read); + let result; + if (nextSlash !== -1) { + result = url.slice(read, nextSlash); + read = nextSlash + 1; + } else { + result = url.slice(read); + read = url.length; + } + return result; + }, + }; +} + function ensureSlashes(href) { if (href[0] !== '/') { href = '/' + href; @@ -242,58 +333,60 @@ function ensureSlashes(href) { return href; } -function isSubpathOf(child, parent) { - const childHref = ensureSlashes(child.join('/')); - const parentHref = ensureSlashes( - parent.segments.slice(0, parent.depth).join('/') - ); - return childHref.indexOf(parentHref) === 0; +function removeSlashes(href) { + if (href[0] === '/') { + href = href.slice(1); + } + if (href[href.length - 1] === '/') { + href = href.slice(0, -1); + } + return href; } // --- Infra (Client) --- const ClientFrameContext = createContext(null); -function ClientLink({match, to, children, isActive, plainWhenActive}) { +function ClientLink({to, target, cursor, active, children}) { const router = useContext(ClientFrameContext); - const href = '/' + to.join('/'); - if (isActive && plainWhenActive) { - return {children}; - } return ( { e.preventDefault(); - router.navigate(href, match); + router.navigate(to, target, cursor); + }} + style={{ + textDecoration: active ? 'none' : '', + color: active ? 'black' : '', + fontWeight: active ? 'bold' : '', }}> {children} ); } -function ClientFrame({preloadedContent, target}) { +function ClientFrame({children, target: ownTarget}) { const parentContext = useContext(ClientFrameContext); - const [content, setContent] = useState(preloadedContent); - const [prevPreloadedContent, setPrevPreloadedContent] = useState( - preloadedContent - ); - if (preloadedContent !== prevPreloadedContent) { + const [content, setContent] = useState(children); + const [prevChildren, setPrevChildren] = useState(children); + if (children !== prevChildren) { // An update to the parent Frame might carry different child frame content. // In that case, prefer it to what the child was already showing. - setContent(preloadedContent); - setPrevPreloadedContent(preloadedContent); + setContent(children); + setPrevChildren(children); } return ( ); + history.pushState(null, null, to); // TODO: How should the Back button work? }, }}>