Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Sync-ish Suspense #46

Merged
merged 6 commits into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eleven-apes-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"simple-stack-stream": minor
---

Don't show a fallback if the content renders quickly enough (current timeout: 5ms)
5 changes: 5 additions & 0 deletions .changeset/rotten-rules-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"simple-stack-stream": patch
---

Reduced the size of the `<script>` tags that insert streamed content into the DOM
25 changes: 22 additions & 3 deletions examples/playground/src/pages/stream.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import Wait from "../components/Wait.astro";
import { Suspense, } from 'simple-stack-stream/components'
---
<Layout>
<h1>Out of order streaming</h1>

<main class="flex flex-col gap-2 max-w-prose mx-2">
<h1 class="font-bold text-xl">Out of order streaming</h1>
<!-- out-of-order streaming: fallback,
JS to swap content -->
<Suspense>
<Wait ms={2000}>
<p class="font-bold text-xl">Content</p>
<p class="font-bold text-lg">Slow content</p>
</Wait>
<p slot="fallback">Loading...</p>
<p class="rounded p-2 border-gray-300 border-2" slot="fallback">Loading...</p>
</Suspense>

<!-- in-order HTML streaming (no client JS) -->
Expand All @@ -21,4 +23,21 @@ import { Suspense, } from 'simple-stack-stream/components'
<p>Join the newsletter</p>
</footer>
</Wait>

<Suspense>
<p slot="fallback">Loading... (shouldn't appear)</p>
<div class="rounded p-2 border-gray-300 border-2">
<p class="font-bold text-lg">Synchronous content wrapped in a Suspense</p>
<p>(it shouldn't show a fallback)</p>
</div>
</Suspense>

<Suspense>
<p slot="fallback">Loading... (also shouldn't appear)</p>
<Wait ms={1}>
<p class="font-bold text-lg">More content</p>
<p>It's only delayed by only 1ms, so it shouldn't show a fallback either</p>
</Wait>
</Suspense>
</main>
</Layout>
51 changes: 46 additions & 5 deletions packages/stream/components/Suspense.astro
Original file line number Diff line number Diff line change
@@ -1,8 +1,49 @@
---
const idx = Astro.locals.suspend(Astro.slots.render("default"));
---
import { trackPromiseState, sleep } from "./utils";

const thenable = trackPromiseState(Astro.slots.render("default"));

// Wait a moment to see if the slot renders quickly enough --
// if it does, there's no need to send a fallback.
const DEADLINE_MS = 5;
await sleep(DEADLINE_MS);

<div style="display: contents" data-suspense-fallback={idx}>
<slot name="fallback" />
</div>
type RenderKind = { kind: 'fallback', id: number } | { kind: 'content', content: string }
bholmesdev marked this conversation as resolved.
Show resolved Hide resolved

// Check if the slot managed to render in time.
// Note that after this point, we shouldn't rely on `thenable.status` --
// we need to make sure that what we do here matches what the middleware does,
// but thenable.status might change if the promise finishes sometime in the meantime,
// which'd result in a race condition.
let renderKind: RenderKind;

switch (thenable.status) {
case "pending": {
const id = Astro.locals.suspend(thenable);
renderKind = { kind: 'fallback', id }
break;
}
case "rejected": {
throw thenable.reason;
}
case "fulfilled": {
if (import.meta.env.DEV) {
console.log(
`Suspense :: slot resolved within deadline (${DEADLINE_MS}ms), no need to show fallback`
);
}
renderKind = { kind: 'content', content: thenable.value }
break;
}
}
---

{
renderKind.kind === 'fallback' ? (
<div style="display: contents" data-suspense-fallback={renderKind.id}>
<slot name="fallback" />
</div>
) : (
<Fragment set:html={renderKind.content} />
)
}
32 changes: 32 additions & 0 deletions packages/stream/components/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
type PendingThenableState = { status: "pending" };
type FulfilledThenableState<T> = { status: "fulfilled"; value: T };
type RejectedThenableState = { status: "rejected"; reason: unknown };

type ThenableState<T> =
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: wondering if Promise would be a better name than Thenable for these variables?

  • PendingPromiseState
  • FulfilledPromiseState
    ...

I understand we want to differentiate from standard promises. Though I'm not sure how much the name "thenable" is gaining us here. Took me a second to equate the two.

Copy link
Contributor Author

@lubieowoce lubieowoce Feb 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH i basically copied React's naming and conventions around this wholesale :D
https://github.com/facebook/react/blob/54f2314e9cfe82baf1c040a55ed4dfff6488f84e/packages/shared/ReactTypes.js/#L126-L148
I guess that it's some sort of precedent, so i thought i'd just stick with that

i suppose you could say that they're calling it a Thenable because they only rely on a subset of the Promise API:

// The subset of a Promise that React APIs rely on. This resolves a value.
// This doesn't require a return value neither from the handler nor the
// then function.
interface ThenableImpl<T> {
  then(
    onFulfill: (value: T) => mixed,
    onReject: (error: mixed) => mixed,
  ): void | Wakeable;
}

...and in our case it's more like a TrackedPromise. but i do kinda like Thenable better, TrackedPromise is a bit unwieldy

Copy link
Owner

@bholmesdev bholmesdev Feb 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good to know there's precedence. To be honest, I'm still not sold on the naming, but I don't feel strongly enough about it to block on changes. We can keep this

| PendingThenableState
| FulfilledThenableState<T>
| RejectedThenableState;

export type Thenable<T> = Promise<T> & ThenableState<T>;

export function trackPromiseState<T>(promise: Promise<T>): Thenable<T> {
const thenable = promise as Promise<T> & PendingThenableState;
thenable.status = "pending";
thenable.then(
(value) => {
const fulfilled = promise as Promise<T> & FulfilledThenableState<T>;
fulfilled.status = "fulfilled";
fulfilled.value = value;
},
(error) => {
const rejected = promise as Promise<T> & RejectedThenableState;
rejected.status = "rejected";
rejected.reason = error;
},
);
return thenable;
}

export function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
74 changes: 39 additions & 35 deletions packages/stream/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,61 @@ type SuspendedChunk = {
};

export const onRequest = defineMiddleware(async (ctx, next) => {
const response = await next();
// ignore non-HTML responses
if (!response.headers.get("content-type")?.startsWith("text/html")) {
return response;
}

let streamController: ReadableStreamDefaultController<SuspendedChunk>;

async function* render() {
// Thank you owoce!
// https://gist.github.com/lubieowoce/05a4cb2e8cd252787b54b7c8a41f09fc
const stream = new ReadableStream<SuspendedChunk>({
start(controller) {
streamController = controller;
},
});
// Thank you owoce!
// https://gist.github.com/lubieowoce/05a4cb2e8cd252787b54b7c8a41f09fc
const stream = new ReadableStream<SuspendedChunk>({
start(controller) {
streamController = controller;
},
});

let curId = 0;
const pending = new Set<Promise<string>>();
let curId = 0;
const pending = new Set<Promise<string>>();

ctx.locals.suspend = (promise) => {
const idx = curId++;
pending.add(promise);
promise
.then((chunk) => {
ctx.locals.suspend = (promise) => {
const idx = curId++;
pending.add(promise);
promise
.then((chunk) => {
try {
streamController.enqueue({ chunk, idx });
} finally {
pending.delete(promise);
})
.catch((e) => {
streamController.error(e);
});
return idx;
};
}
})
.catch((e) => {
streamController.error(e);
});
return idx;
};

const response = await next();

// ignore non-HTML responses
if (!response.headers.get("content-type")?.startsWith("text/html")) {
return response;
}

async function* render() {
// @ts-expect-error ReadableStream does not have asyncIterator
for await (const chunk of response.body) {
yield chunk;
}

if (!pending.size) return streamController.close();

yield `<script>window.__SIMPLE_SUSPENSE_INSERT = function (idx) {
var template = document.querySelector('[data-suspense="' + idx + '"]').content;
var dest = document.querySelector('[data-suspense-fallback="' + idx + '"]');
dest.replaceWith(template);
}</script>`;

// @ts-expect-error ReadableStream does not have asyncIterator
for await (const { chunk, idx } of stream) {
yield `<template data-suspense=${JSON.stringify(idx)}>${chunk}</template>
<script>
(() => {
const template = document.querySelector(\`[data-suspense="${idx}"]\`).content;
const dest = document.querySelector(\`[data-suspense-fallback="${idx}"]\`);
dest.replaceWith(template);
})();
</script>`;
yield `<template data-suspense=${idx}>${chunk}</template>` +
`<script>window.__SIMPLE_SUSPENSE_INSERT(${idx});</script>`;
if (!pending.size) return streamController.close();
}
}
Expand Down
Loading