diff --git a/examples/suspense/.gitignore b/examples/suspense/.gitignore new file mode 100644 index 000000000..6240da8b1 --- /dev/null +++ b/examples/suspense/.gitignore @@ -0,0 +1,21 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store diff --git a/examples/suspense/.vscode/extensions.json b/examples/suspense/.vscode/extensions.json new file mode 100644 index 000000000..22a15055d --- /dev/null +++ b/examples/suspense/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/examples/suspense/.vscode/launch.json b/examples/suspense/.vscode/launch.json new file mode 100644 index 000000000..d64220976 --- /dev/null +++ b/examples/suspense/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/examples/suspense/README.md b/examples/suspense/README.md new file mode 100644 index 000000000..e81359a7d --- /dev/null +++ b/examples/suspense/README.md @@ -0,0 +1,47 @@ +# Astro Starter Kit: Minimal + +``` +npm create astro@latest -- --template minimal +``` + +[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal) +[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal) +[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json) + +> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun! + +## 🚀 Project Structure + +Inside of your Astro project, you'll see the following folders and files: + +``` +/ +├── public/ +├── src/ +│ └── pages/ +│ └── index.astro +└── package.json +``` + +Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. + +There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. + +Any static assets, like images, can be placed in the `public/` directory. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :--------------------- | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:3000` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro --help` | Get help using the Astro CLI | + +## 👀 Want to learn more? + +Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/examples/suspense/astro.config.mjs b/examples/suspense/astro.config.mjs new file mode 100644 index 000000000..97e31f681 --- /dev/null +++ b/examples/suspense/astro.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'astro/config'; + +import node from "@astrojs/node"; + +// https://astro.build/config +export default defineConfig({ + output: "server", + adapter: node({ + mode: "standalone" + }) +}); \ No newline at end of file diff --git a/examples/suspense/package.json b/examples/suspense/package.json new file mode 100644 index 000000000..478c4131d --- /dev/null +++ b/examples/suspense/package.json @@ -0,0 +1,17 @@ +{ + "name": "@example/suspense", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/node": "^5.1.0", + "astro": "^2.1.3" + } +} diff --git a/examples/suspense/public/favicon.svg b/examples/suspense/public/favicon.svg new file mode 100644 index 000000000..f157bd1c5 --- /dev/null +++ b/examples/suspense/public/favicon.svg @@ -0,0 +1,9 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128"> + <path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" /> + <style> + path { fill: #000; } + @media (prefers-color-scheme: dark) { + path { fill: #FFF; } + } + </style> +</svg> diff --git a/examples/suspense/sandbox.config.json b/examples/suspense/sandbox.config.json new file mode 100644 index 000000000..9178af77d --- /dev/null +++ b/examples/suspense/sandbox.config.json @@ -0,0 +1,11 @@ +{ + "infiniteLoopProtection": true, + "hardReloadOnChange": false, + "view": "browser", + "template": "node", + "container": { + "port": 3000, + "startScript": "start", + "node": "14" + } +} diff --git a/examples/suspense/src/components/Delay.astro b/examples/suspense/src/components/Delay.astro new file mode 100644 index 000000000..3c5aee7c5 --- /dev/null +++ b/examples/suspense/src/components/Delay.astro @@ -0,0 +1,7 @@ +--- +import { setTimeout as sleep } from 'node:timers/promises'; + +await sleep(Math.random() * 5000); +--- + +<h2><slot /></h2> diff --git a/examples/suspense/src/components/Greeting.astro b/examples/suspense/src/components/Greeting.astro new file mode 100644 index 000000000..103328ab8 --- /dev/null +++ b/examples/suspense/src/components/Greeting.astro @@ -0,0 +1,55 @@ +--- +import Delay from './Delay.astro'; +import Suspense from './Suspense.js'; +--- + +<div> + <Suspense> + <h2 + class="skeleton-box" + style={{ '--width': `${10 + Math.floor(Math.random() * 30)}%` }} + slot="fallback" + > + </h2> + <Delay><slot /></Delay> + </Suspense> +</div> + +<style> + h2 { + width: 100%; + } + .skeleton-box { + display: inline-block; + height: 1em; + width: var(--width); + position: relative; + overflow: hidden; + background-color: #ddd; + color: transparent; + margin: 0; + } + .skeleton-box::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + transform: translateX(-100%); + background-image: linear-gradient( + to right, + rgb(255 255 255 / 0) 0%, + rgb(255 255 255 / 20%) 20%, + rgb(255 255 255 / 50%) 60%, + rgb(255 255 255 / 0) 100% + ); + animation: shimmer 1s infinite; + content: ''; + } + + @keyframes shimmer { + 100% { + transform: translateX(100%); + } + } +</style> diff --git a/examples/suspense/src/components/Suspense.js b/examples/suspense/src/components/Suspense.js new file mode 100644 index 000000000..ffb897ea7 --- /dev/null +++ b/examples/suspense/src/components/Suspense.js @@ -0,0 +1,17 @@ +import { createComponent, render, renderSlot, renderSuspense } from 'astro/runtime/server/index.js'; + +let ids = new WeakMap(); +export default createComponent({ + factory(result, props, slots) { + const id = (ids.get(result) ?? -1) + 1; + ids.set(result, id); + let suspense = { status: 'pending' } + suspense.value = renderSuspense(result, id, slots.default).then((result) => { + suspense.status = 'fulfilled'; + return result; + }) + result.suspense.set(id, suspense); + + return render`<astro-placeholder uid="${id}">${renderSlot(result, slots.fallback)}</astro-placeholder>` + } +}) diff --git a/examples/suspense/src/env.d.ts b/examples/suspense/src/env.d.ts new file mode 100644 index 000000000..f964fe0cf --- /dev/null +++ b/examples/suspense/src/env.d.ts @@ -0,0 +1 @@ +/// <reference types="astro/client" /> diff --git a/examples/suspense/src/pages/index.astro b/examples/suspense/src/pages/index.astro new file mode 100644 index 000000000..417032ba9 --- /dev/null +++ b/examples/suspense/src/pages/index.astro @@ -0,0 +1,43 @@ +--- +import Greeting from '../components/Greeting.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> + <main> + <Greeting>Hello world</Greeting> + <Greeting>Hola mundo</Greeting> + <Greeting>Bonjour le monde</Greeting> + <Greeting>Hallo Welt</Greeting> + </main> + </body> +</html> + +<style> + :global(*) { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + padding: 1rem; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, + Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + } + + main { + display: flex; + flex-direction: column; + padding-top: 2rem; + gap: 1rem; + } +</style> diff --git a/examples/suspense/tsconfig.json b/examples/suspense/tsconfig.json new file mode 100644 index 000000000..d78f81ec4 --- /dev/null +++ b/examples/suspense/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/base" +} diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 2d4bcfa15..483fc5950 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1588,6 +1588,7 @@ export interface SSRResult { ): AstroGlobal; resolve: (s: string) => Promise<string>; response: ResponseInit; + suspense: Map<string, { status: 'pending' | 'fulfilled' | 'rendered', value: any | Promise<any> }>; // Bits 1 = astro, 2 = jsx, 4 = slot // As rendering occurs these bits are manipulated to determine where content // is within a slot. This is used for head injection. diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index f1474a0a3..bb08548e0 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -160,6 +160,7 @@ export function createResult(args: CreateResultArgs): SSRResult { links: args.links ?? new Set<SSRElement>(), propagation: args.propagation ?? new Map(), propagators: new Map(), + suspense: new Map(), extraHead: [], scope: 0, cookies, diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index cae48fd41..6eea21eb5 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -21,6 +21,7 @@ export { renderPage, renderScriptElement, renderSlot, + renderSuspense, renderStyleElement, renderTemplate as render, renderTemplate, diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts index 9a0839e51..f217430b4 100644 --- a/packages/astro/src/runtime/server/render/astro/instance.ts +++ b/packages/astro/src/runtime/server/render/astro/instance.ts @@ -8,6 +8,7 @@ import { renderChild } from '../any.js'; import { createScopedResult, ScopeFlags } from '../scope.js'; import { isAPropagatingComponent } from './factory.js'; import { isHeadAndContent } from './head-and-content.js'; +import { markHTMLString } from '../../escape.js'; type ComponentProps = Record<string | number, any>; diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index 76faffdbf..ae1ffe63b 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -21,7 +21,7 @@ export const decoder = new TextDecoder(); // Rendering produces either marked strings of HTML or instructions for hydration. // These directive instructions bubble all the way up to renderPage so that we // can ensure they are added only once, and as soon as possible. -export function stringifyChunk(result: SSRResult, chunk: string | SlotString | RenderInstruction) { +export function stringifyChunk(result: SSRResult, chunk: string | SlotString | RenderInstruction): string { if (typeof (chunk as any).type === 'string') { const instruction = chunk as RenderInstruction; switch (instruction.type) { @@ -43,6 +43,12 @@ export function stringifyChunk(result: SSRResult, chunk: string | SlotString | R return ''; } } + case 'suspense': { + const { id } = instruction; + const content = stringifyChunk(result, instruction.content); + const html = JSON.stringify(content).replace(/\<\/script\>/g, `</" + "script>`); + return markHTMLString(`<script async defer>document.querySelector("astro-placeholder[uid='${id}']").outerHTML = ${html};document.currentScript.remove();</script>`); + } case 'head': { if (result._metadata.hasRenderedHead) { return ''; diff --git a/packages/astro/src/runtime/server/render/index.ts b/packages/astro/src/runtime/server/render/index.ts index 4f7e14c9d..94bc6f7f8 100644 --- a/packages/astro/src/runtime/server/render/index.ts +++ b/packages/astro/src/runtime/server/render/index.ts @@ -12,6 +12,7 @@ export { maybeRenderHead, renderHead } from './head.js'; export { renderPage } from './page.js'; export { addScopeFlag, createScopedResult, removeScopeFlag, ScopeFlags } from './scope.js'; export { renderSlot, type ComponentSlots } from './slot.js'; +export { renderSuspense } from './suspense.js'; export { renderScriptElement, renderStyleElement, renderUniqueStylesheet } from './tags.js'; export type { RenderInstruction } from './types'; export { addAttribute, defineScriptVars, voidElementNames } from './util.js'; diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts index 857cfdd6a..fd42c08c0 100644 --- a/packages/astro/src/runtime/server/render/page.ts +++ b/packages/astro/src/runtime/server/render/page.ts @@ -153,6 +153,9 @@ export async function renderPage( controller.enqueue(bytes); i++; } + + await Promise.all(Array.from(result.suspense.values()).map(suspense => suspense.value.then((chunk: any) => controller.enqueue(chunkToByteArray(result, chunk))))) + controller.close(); } catch (e) { // We don't have a lot of information downstream, and upstream we can't catch the error properly diff --git a/packages/astro/src/runtime/server/render/suspense.ts b/packages/astro/src/runtime/server/render/suspense.ts new file mode 100644 index 000000000..12c25acc6 --- /dev/null +++ b/packages/astro/src/runtime/server/render/suspense.ts @@ -0,0 +1,18 @@ +import type { SSRResult } from '../../../@types/astro.js'; +import type { RenderInstruction } from './types.js'; + +import { markHTMLString } from '../escape.js'; +import { renderSlot } from './slot.js'; + +export async function renderSuspense( + result: SSRResult, + id: string, + slotted: any, +) { + if (slotted) { + const content = await renderSlot(result, slotted); + return { type: 'suspense', id, content } + } + + return ''; +} diff --git a/packages/astro/src/runtime/server/render/types.ts b/packages/astro/src/runtime/server/render/types.ts index 31702f444..808e61082 100644 --- a/packages/astro/src/runtime/server/render/types.ts +++ b/packages/astro/src/runtime/server/render/types.ts @@ -7,6 +7,13 @@ export type RenderDirectiveInstruction = { hydration: HydrationMetadata; }; +export type RenderSuspenseInstruction = { + type: 'suspense'; + result: SSRResult; + id: string; + content: any; +}; + export type RenderHeadInstruction = { type: 'head'; result: SSRResult; @@ -20,5 +27,6 @@ export type MaybeRenderHeadInstruction = { export type RenderInstruction = | RenderDirectiveInstruction + | RenderSuspenseInstruction | RenderHeadInstruction | MaybeRenderHeadInstruction; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44e6f5467..e5cd32afe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,14 @@ importers: unocss: 0.15.6 vite-imagetools: 4.0.18 + examples/suspense: + specifiers: + '@astrojs/node': ^5.1.0 + astro: ^2.1.3 + dependencies: + '@astrojs/node': link:../../packages/integrations/node + astro: link:../../packages/astro + examples/with-markdoc: specifiers: '@astrojs/markdoc': ^0.0.2 @@ -4194,14 +4202,15 @@ packages: - react dev: false - /@astrojs/markdown-remark/2.0.1_astro@packages+astro: - resolution: {integrity: sha512-xQF1rXGJN18m+zZucwRRtmNehuhPMMhZhi6HWKrtpEAKnHSPk8lqf1GXgKH7/Sypglu8ivdECZ+EGs6kOYVasQ==} + /@astrojs/markdown-remark/2.1.0_astro@packages+astro: + resolution: {integrity: sha512-w9T5o3UWQIfMcCkM2nLWrlfVQazh/7mw+2N/85QGcSUkZy6oNJoyy8Xz/ZkDhHLx8HPO0RT9fABR0B/H+aDaEw==} peerDependencies: astro: '*' dependencies: - '@astrojs/prism': 2.0.0 + '@astrojs/prism': 2.1.1 astro: link:packages/astro github-slugger: 1.5.0 + image-size: 1.0.2 import-meta-resolve: 2.2.1 rehype-raw: 6.1.1 rehype-stringify: 9.0.3 @@ -4221,8 +4230,8 @@ packages: resolution: {integrity: sha512-mol57cw1jJMcQgKMRGn7p6cewajq6JTNtqj5aAZgROWam/phVDSOCbXj/WU3O9+3qFnyKtpczoufQKwJTQltAw==} engines: {node: '>=16.12.0'} dependencies: - '@astrojs/markdown-remark': 2.0.1_astro@packages+astro - '@astrojs/prism': 2.0.0 + '@astrojs/markdown-remark': 2.1.0_astro@packages+astro + '@astrojs/prism': 2.1.1 '@mdx-js/mdx': 2.3.0 '@mdx-js/rollup': 2.3.0 acorn: 8.8.2 @@ -4266,8 +4275,8 @@ packages: - supports-color dev: false - /@astrojs/prism/2.0.0: - resolution: {integrity: sha512-YgeoeEPqsxaEpg0rwe/bUq3653LqSQnMjrLlpYwrbQQMQQqz6Y5yXN+RX3SfLJ6ppNb4+Fu2+Z49EXjk48Ihjw==} + /@astrojs/prism/2.1.1: + resolution: {integrity: sha512-Gnwnlb1lGJzCQEg89r4/WqgfCGPNFC7Kuh2D/k289Cbdi/2PD7Lrdstz86y1itDvcb2ijiRqjqWnJ5rsfu/QOA==} engines: {node: '>=16.12.0'} dependencies: prismjs: 1.29.0 @@ -15007,6 +15016,7 @@ packages: /source-map/0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + requiresBuild: true /source-map/0.7.4: resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}