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?
},
}}>