spike: client-side routing

This commit is contained in:
Nate Moore 2023-04-14 05:56:27 -05:00
parent 4516d7b22c
commit d8608bb947
19 changed files with 369 additions and 17 deletions

View file

@ -1,4 +1,10 @@
import { defineConfig } from 'astro/config';
import node from "@astrojs/node";
// https://astro.build/config
export default defineConfig({});
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone"
})
});

View file

@ -11,6 +11,7 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^5.1.1",
"astro": "^2.4.1"
}
}

View file

@ -0,0 +1,3 @@
<div id="deep">
<slot />
</div>

View file

@ -0,0 +1,10 @@
---
const { tab } = Astro.props;
---
<ul>
<li><a class:list={{ active: tab === '1' }} href="/a">Tab 1</a></li>
<li><a class:list={{ active: tab === '2' }} href="/a/2">Tab 2</a></li>
<li><a class:list={{ active: tab === '3' }} href="/a/3">Tab 3</a></li>
</ul>
<div>Tab {tab}</div>

View file

@ -0,0 +1,132 @@
---
import { Outlet } from 'astro/components';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<header>
<h1>My Website</h1>
</header>
<slot name="nav" />
<main>
{() => new Promise((resolve) => setTimeout(resolve, 1000))}
<slot />
{() => new Promise((resolve) => setTimeout(resolve, 1000))}
<slot name="tabs" />
</main>
<script>
function getOutlets() {
const query = "//comment()[contains(., 'astro:outlet')]";
const comments = document.evaluate(
query,
document,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
const outlets: Record<string, Range> = {};
let fragment = [];
let stack = 0;
for (let i = 0; i < comments.snapshotLength; i++) {
const comment = comments.snapshotItem(i);
const end = comment.textContent?.trim()?.[0] === '/';
if (!end) {
if (stack === 0) fragment.push(comment);
stack++;
} else {
stack--;
if (stack === 0) {
const start = fragment[0];
const end = comment;
const range = new Range();
range.setStartAfter(start);
range.setEndBefore(end);
outlets[start.textContent.replace('astro:outlet', '').trim()] = range;
fragment.length = 0;
}
}
}
return outlets;
}
async function streamToRange(response: Response, range: Range) {
if (response.status !== 200) {
range.deleteContents();
return;
}
const doc = document.implementation.createHTMLDocument();
doc.write('<streaming-root>');
const root = doc.querySelector('streaming-root');
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let init = false;
const mo = new MutationObserver((entries) => {
if (!init) {
range.deleteContents();
init = true;
}
for (const entry of entries) {
for (const child of entry.addedNodes) {
range.insertNode(child);
}
}
});
mo.observe(root, { childList: true });
let result = await reader.read();
while (!result.done) {
doc.write(result.value);
result = await reader.read();
}
}
navigation.addEventListener('navigate', (event) => {
event.intercept({
async handler() {
async function update() {
const promises: any[] = [];
for (const [outlet, range] of Object.entries(getOutlets())) {
promises.push(
fetch(event.destination.url, {
headers: { 'x-astro-outlet': outlet },
}).then((res) => streamToRange(res, range))
);
}
return Promise.all(promises);
}
// if ('startViewTransition' in document) {
// await document.startViewTransition(() => update());
// } else {
await update();
// }
},
});
});
</script>
<style is:global>
nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 1rem;
}
a.active {
color: red;
}
</style>
</body>
</html>

View file

@ -0,0 +1,22 @@
---
import Layout from '../../layouts/Default.astro';
import Tab from '../../component/Tabs.astro';
const { tab = '' } = Astro.params;
---
<Layout>
<div>
<h2>Page A</h2>
</div>
<Tab slot="tabs" tab={tab} />
<nav slot="nav">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/a" class="active">Page A</a></li>
<li><a href="/b">Page B</a></li>
<li><a href="/c">Page C</a></li>
</ul>
</nav>
</Layout>

View file

@ -0,0 +1,21 @@
---
import Layout from '../../layouts/Default.astro';
import Tab from '../../component/Tabs.astro';
---
<Layout>
<div>
<h2>Page A</h2>
</div>
<Tab slot="tabs" tab="1" />
<nav slot="nav">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/a" class="active">Page A</a></li>
<li><a href="/b">Page B</a></li>
<li><a href="/c">Page C</a></li>
</ul>
</nav>
</Layout>

View file

@ -0,0 +1,20 @@
---
import Layout from '../layouts/Default.astro';
import Deep from '../component/Deep.astro';
---
<Layout>
<Deep>
<h2>Page B</h2>
<p>No delay</p>
</Deep>
<nav slot="nav">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/a">Page A</a></li>
<li><a href="/b" class="active">Page B</a></li>
<li><a href="/c">Page C</a></li>
</ul>
</nav>
</Layout>

View file

@ -0,0 +1,22 @@
---
import Layout from '../layouts/Default.astro';
import { setTimeout as sleep } from 'node:timers/promises';
---
<Layout>
<div>
<h2>Page C</h2>
<p>This is fast</p>
{sleep(1000)}
<p>This is slow</p>
</div>
<nav slot="nav">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/a">Page A</a></li>
<li><a href="/b">Page B</a></li>
<li><a href="/c" class="active">Page C</a></li>
</ul>
</nav>
</Layout>

View file

@ -1,15 +1,19 @@
---
import Layout from '../layouts/Default.astro';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<h1>Astro</h1>
</body>
</html>
<Layout>
<div>
<h2>Home</h2>
<p>Streaming partials!</p>
</div>
<nav slot="nav">
<ul>
<li><a href="/" class="active">Home</a></li>
<li><a href="/a">Page A</a></li>
<li><a href="/b">Page B</a></li>
<li><a href="/c">Page C</a></li>
</ul>
</nav>
</Layout>

View file

@ -0,0 +1,14 @@
---
interface Props {
id: string;
}
const { id } = Astro.props;
// @ts-ignore untyped internals
// $$result.outlets.set(id, $$slots.default);
---
<Fragment set:html={`<!--astro:outlet ${id}-->`} /><slot /><Fragment
set:html={`<!--/astro:outlet ${id}-->`}
/>

View file

@ -1,2 +1,3 @@
export { default as Code } from './Code.astro';
export { default as Debug } from './Debug.astro';
export { default as Outlet } from './Outlet.astro';

View file

@ -1750,6 +1750,7 @@ export interface SSRMetadata {
hasDirectives: Set<string>;
hasRenderedHead: boolean;
headInTree: boolean;
request: Request;
}
/**
@ -1775,7 +1776,9 @@ export interface SSRResult {
links: Set<SSRElement>;
componentMetadata: Map<string, SSRComponentMetadata>;
propagators: Map<AstroComponentFactory, AstroComponentInstance>;
outletPropagators: Map<string, any>;
extraHead: Array<string>;
outlets: Map<string, any>;
cookies: AstroCookies | undefined;
createAstro(
Astro: AstroGlobalPartial,

View file

@ -163,7 +163,9 @@ export function createResult(args: CreateResultArgs): SSRResult {
links: args.links ?? new Set<SSRElement>(),
componentMetadata,
propagators: new Map(),
outletPropagators: new Map(),
extraHead: [],
outlets: new Map<string, any>(),
scope: 0,
cookies,
/** This function returns the `Astro` faux-global */
@ -260,6 +262,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
hasRenderedHead: false,
hasDirectives: new Set(),
headInTree: false,
request
},
response,
};

View file

@ -7,7 +7,7 @@ function validateArgs(args: unknown[]): args is Parameters<AstroComponentFactory
if (!args[0] || typeof args[0] !== 'object') return false;
return true;
}
function baseCreateComponent(cb: AstroComponentFactory, moduleId?: string): AstroComponentFactory {
function baseCreateComponent(cb: AstroComponentFactory, moduleId?: string, hash?: string): AstroComponentFactory {
const name = moduleId?.split('/').pop()?.replace('.astro', '') ?? '';
const fn = (...args: Parameters<AstroComponentFactory>) => {
if (!validateArgs(args)) {
@ -22,6 +22,7 @@ function baseCreateComponent(cb: AstroComponentFactory, moduleId?: string): Astr
// Add a flag to this callback to mark it as an Astro component
fn.isAstroComponentFactory = true;
fn.moduleId = moduleId;
fn.hash = hash;
return fn;
}
@ -29,20 +30,23 @@ interface CreateComponentOptions {
factory: AstroComponentFactory;
moduleId?: string;
propagation?: PropagationHint;
hash?: string;
}
function createComponentWithOptions(opts: CreateComponentOptions) {
const cb = baseCreateComponent(opts.factory, opts.moduleId);
cb.propagation = opts.propagation;
cb.hash = opts.hash;
return cb;
}
// Used in creating the component. aka the main export.
export function createComponent(
arg1: AstroComponentFactory | CreateComponentOptions,
moduleId?: string
moduleId?: string,
hash?: string
) {
if (typeof arg1 === 'function') {
return baseCreateComponent(arg1, moduleId);
return baseCreateComponent(arg1, moduleId, hash);
} else {
return createComponentWithOptions(arg1);
}

View file

@ -14,6 +14,7 @@ export interface AstroComponentFactory {
isAstroComponentFactory?: boolean;
moduleId?: string | undefined;
propagation?: PropagationHint;
hash?: string;
}
export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory {

View file

@ -81,6 +81,12 @@ export function createAstroComponentInstance(
) {
validateComponentProps(props, displayName);
const instance = new AstroComponentInstance(result, props, slots, factory);
if (result._metadata.request.headers.has('x-astro-outlet')) {
for (const [name, slot] of Object.entries(slots)) {
result.outletPropagators.set(`${factory.hash} ${name}`, slot);
}
return instance;
}
if (isAPropagatingComponent(result, factory) && !result.propagators.has(factory)) {
result.propagators.set(factory, instance);
}

View file

@ -67,6 +67,23 @@ async function bufferHeadContent(result: SSRResult) {
}
}
// Recursively calls component instances that might have slots to be propagated up.
async function bufferSlottedContent(result: SSRResult, outlet: string) {
const iterator = result.outletPropagators.entries();
let promises = [];
while (true) {
const { value: [name, slot] = [], done } = iterator.next();
if (done) {
break;
}
const returnValue = await slot(result);
result.outlets.set(name, returnValue);
if (name === outlet) {
break;
}
}
}
export async function renderPage(
result: SSRResult,
componentFactory: AstroComponentFactory | NonAstroPageComponent,
@ -131,7 +148,66 @@ export async function renderPage(
result.componentMetadata.get(componentFactory.moduleId!)?.containsHead ?? false;
const factoryReturnValue = await componentFactory(result, props, children);
const factoryIsHeadAndContent = isHeadAndContent(factoryReturnValue);
if (isRenderTemplateResult(factoryReturnValue) || factoryIsHeadAndContent) {
if (result._metadata.request.headers.get('x-astro-outlet') && (isRenderTemplateResult(factoryReturnValue) || factoryIsHeadAndContent)) {
const outlet = result._metadata.request.headers.get('x-astro-outlet')!;
result.scripts.clear();
await bufferSlottedContent(result, outlet);
if (!result.outlets.get(outlet)) {
let init = result.response;
let headers = new Headers(init.headers);
let response = createResponse(null, { ...init, headers, status: 404 });
return response;
}
let init = result.response;
let headers = new Headers(init.headers);
let body: BodyInit;
if (streaming) {
body = new ReadableStream({
start(controller) {
async function read() {
try {
const template = result.outlets.get(outlet);
for await (const chunk of renderAstroTemplateResult(template)) {
const bytes = chunkToByteArray(result, chunk);
controller.enqueue(bytes);
}
controller.close();
} catch (e) {
// We don't have a lot of information downstream, and upstream we can't catch the error properly
// So let's add the location here
if (AstroError.is(e) && !e.loc) {
e.setLocation({
file: route?.component,
});
}
controller.error(e);
}
}
read();
},
});
} else {
let iterable = (async function* () {
for (const [key, slot] of result.outlets) {
yield `<astro-outlet id="${key}">\n`;
for await (const chunk of renderAstroTemplateResult(slot)) {
const bytes = chunkToByteArray(result, chunk);
yield bytes;
}
yield '\n</astro-outlet>\n\n';
}
})()
body = await iterableToHTMLBytes(result, iterable);
headers.set('Content-Length', body.byteLength.toString());
}
let response = createResponse(body, { ...init, headers });
return response;
} else if (isRenderTemplateResult(factoryReturnValue) || factoryIsHeadAndContent) {
// Wait for head content to be buffered up
await bufferHeadContent(result);
const templateResult = factoryIsHeadAndContent

View file

@ -375,6 +375,9 @@ importers:
examples/minimal:
dependencies:
'@astrojs/node':
specifier: ^5.1.1
version: link:../../packages/integrations/node
astro:
specifier: ^2.4.1
version: link:../../packages/astro