Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/better-carrots-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astrojs/svelte': patch
'@astrojs/react': patch
'@astrojs/vue': patch
---

Improves type-safety of renderers
Copy link
Member Author

Choose a reason for hiding this comment

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

Technically a changeset is not required since it's a refactor but I prefer to add one here to play it safe

8 changes: 8 additions & 0 deletions packages/integrations/react/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
declare module 'astro:react:opts' {
type Options = Pick<
import('./src/index.js').ReactIntegrationOptions,
'experimentalDisableStreaming' | 'experimentalReactChildren'
>;
const options: Options;
export = options;
}
22 changes: 6 additions & 16 deletions packages/integrations/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,15 @@
"exports": {
".": "./dist/index.js",
"./actions": "./dist/actions.js",
"./client.js": "./client.js",
"./client-v17.js": "./client-v17.js",
"./server.js": "./server.js",
"./server-v17.js": "./server-v17.js",
"./client.js": "./dist/client.js",
"./client-v17.js": "./dist/client-v17.js",
"./server.js": "./dist/server.js",
"./server-v17.js": "./dist/server-v17.js",
"./package.json": "./package.json",
"./jsx-runtime": "./jsx-runtime.js"
"./jsx-runtime": "./dist/jsx-runtime.js"
},
"files": [
"dist",
"client.js",
"client-v17.js",
"context.js",
"jsx-runtime.js",
"server.js",
"server.d.ts",
"server-v17.js",
"server-v17.d.ts",
"static-html.js",
"vnode-children.js"
"dist"
],
"scripts": {
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
Expand Down
4 changes: 0 additions & 4 deletions packages/integrations/react/server.d.ts

This file was deleted.

2 changes: 0 additions & 2 deletions packages/integrations/react/server17.d.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { createElement } from 'react';
import { hydrate, render, unmountComponentAtNode } from 'react-dom';
import StaticHtml from './static-html.js';

export default (element) =>
(Component, props, { default: children, ...slotted }, { client }) => {
export default (element: HTMLElement) =>
(
Component: any,
props: Record<string, any>,
{ default: children, ...slotted }: Record<string, any>,
{ client }: Record<string, string>,
) => {
for (const [key, value] of Object.entries(slotted)) {
props[key] = createElement(StaticHtml, { value, name: key });
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { createElement, startTransition } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { type Root, createRoot, hydrateRoot } from 'react-dom/client';
import StaticHtml from './static-html.js';

function isAlreadyHydrated(element) {
function isAlreadyHydrated(element: HTMLElement) {
for (const key in element) {
if (key.startsWith('__reactContainer')) {
return key;
return key as keyof HTMLElement;
}
}
}

function createReactElementFromDOMElement(element) {
let attrs = {};
function createReactElementFromDOMElement(element: any): any {
let attrs: Record<string, string> = {};
for (const attr of element.attributes) {
attrs[attr.name] = attr.value;
}
Expand All @@ -24,7 +24,7 @@ function createReactElementFromDOMElement(element) {
element.localName,
attrs,
Array.from(element.childNodes)
.map((c) => {
.map((c: any) => {
if (c.nodeType === Node.TEXT_NODE) {
return c.data;
} else if (c.nodeType === Node.ELEMENT_NODE) {
Expand All @@ -37,7 +37,7 @@ function createReactElementFromDOMElement(element) {
);
}

function getChildren(childString, experimentalReactChildren) {
function getChildren(childString: string, experimentalReactChildren: boolean) {
if (experimentalReactChildren && childString) {
let children = [];
let template = document.createElement('template');
Expand All @@ -54,8 +54,8 @@ function getChildren(childString, experimentalReactChildren) {
}

// Keep a map of roots so we can reuse them on re-renders
let rootMap = new WeakMap();
const getOrCreateRoot = (element, creator) => {
let rootMap = new WeakMap<HTMLElement, Root>();
const getOrCreateRoot = (element: HTMLElement, creator: () => Root) => {
let root = rootMap.get(element);
if (!root) {
root = creator();
Expand All @@ -64,8 +64,13 @@ const getOrCreateRoot = (element, creator) => {
return root;
};

export default (element) =>
(Component, props, { default: children, ...slotted }, { client }) => {
export default (element: HTMLElement) =>
(
Component: any,
props: Record<string, any>,
{ default: children, ...slotted }: Record<string, any>,
{ client }: Record<string, string>,
) => {
if (!element.hasAttribute('ssr')) return;

const actionKey = element.getAttribute('data-action-key');
Expand Down Expand Up @@ -107,7 +112,7 @@ export default (element) =>
}
startTransition(() => {
const root = getOrCreateRoot(element, () => {
const r = hydrateRoot(element, componentEl, renderOptions);
const r = hydrateRoot(element, componentEl, renderOptions as any);
element.addEventListener('astro:unmount', () => r.unmount(), { once: true });
return r;
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
const contexts = new WeakMap();
import type { SSRResult } from 'astro';

const contexts = new WeakMap<SSRResult, { currentIndex: number; readonly id: string }>();

const ID_PREFIX = 'r';

function getContext(rendererContextResult) {
function getContext(rendererContextResult: SSRResult) {
if (contexts.has(rendererContextResult)) {
return contexts.get(rendererContextResult);
}
Expand All @@ -16,8 +18,8 @@ function getContext(rendererContextResult) {
return ctx;
}

export function incrementId(rendererContextResult) {
const ctx = getContext(rendererContextResult);
export function incrementId(rendererContextResult: SSRResult) {
const ctx = getContext(rendererContextResult)!;
const id = ctx.id;
ctx.currentIndex++;
return id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// it can run in Node ESM. 'react' doesn't declare this module as an export map
// So we have to use the .js. The .js is not added via the babel automatic JSX transform
// hence this module as a workaround.
import jsxr from 'react/jsx-runtime.js';
import jsxr from 'react/jsx-runtime';
const { jsx, jsxs, Fragment } = jsxr;

export { jsx, jsxs, Fragment };
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { AstroComponentMetadata } from 'astro';
import React from 'react';
import ReactDOM from 'react-dom/server.js';
import ReactDOM from 'react-dom/server';
import StaticHtml from './static-html.js';

const slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
const reactTypeof = Symbol.for('react.element');

function check(Component, props, children) {
function check(Component: any, props: Record<string, any>, children: any) {
// Note: there are packages that do some unholy things to create "components".
// Checking the $$typeof property catches most of these patterns.
if (typeof Component === 'object') {
Expand All @@ -19,7 +20,7 @@ function check(Component, props, children) {
}

let isReactComponent = false;
function Tester(...args) {
function Tester(...args: Array<any>) {
try {
const vnode = Component(...args);
if (vnode && vnode['$$typeof'] === reactTypeof) {
Expand All @@ -30,14 +31,19 @@ function check(Component, props, children) {
return React.createElement('div');
}

renderToStaticMarkup(Tester, props, children, {});
renderToStaticMarkup(Tester, props, children, {} as any);

return isReactComponent;
}

function renderToStaticMarkup(Component, props, { default: children, ...slotted }, metadata) {
function renderToStaticMarkup(
Component: any,
props: Record<string, any>,
{ default: children, ...slotted }: Record<string, any>,
metadata: AstroComponentMetadata,
) {
delete props['class'];
const slots = {};
const slots: Record<string, any> = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = React.createElement(StaticHtml, { value, name });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import opts from 'astro:react:opts';
import type { AstroComponentMetadata } from 'astro';
import React from 'react';
import ReactDOM from 'react-dom/server';
import { incrementId } from './context.js';
import StaticHtml from './static-html.js';
import type { RendererContext } from './types.js';

const slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
const reactTypeof = Symbol.for('react.element');
const reactTransitionalTypeof = Symbol.for('react.transitional.element');

async function check(Component, props, children) {
async function check(
this: RendererContext,
Component: any,
props: Record<string, any>,
children: any,
) {
// Note: there are packages that do some unholy things to create "components".
// Checking the $$typeof property catches most of these patterns.
if (typeof Component === 'object') {
Expand All @@ -26,7 +33,7 @@ async function check(Component, props, children) {
}

let isReactComponent = false;
function Tester(...args) {
function Tester(...args: Array<any>) {
try {
const vnode = Component(...args);
if (
Expand All @@ -40,31 +47,37 @@ async function check(Component, props, children) {
return React.createElement('div');
}

await renderToStaticMarkup(Tester, props, children, {});
await renderToStaticMarkup.call(this, Tester, props, children, {} as any);

return isReactComponent;
}

async function getNodeWritable() {
async function getNodeWritable(): Promise<typeof import('node:stream').Writable> {
let nodeStreamBuiltinModuleName = 'node:stream';
let { Writable } = await import(/* @vite-ignore */ nodeStreamBuiltinModuleName);
return Writable;
}

function needsHydration(metadata) {
function needsHydration(metadata: AstroComponentMetadata) {
// Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot`
return metadata.astroStaticSlot ? !!metadata.hydrate : true;
}

async function renderToStaticMarkup(Component, props, { default: children, ...slotted }, metadata) {
async function renderToStaticMarkup(
this: RendererContext,
Component: any,
props: Record<string, any>,
{ default: children, ...slotted }: Record<string, any>,
metadata: AstroComponentMetadata,
) {
let prefix;
if (this && this.result) {
prefix = incrementId(this.result);
}
const attrs = { prefix };
const attrs: Record<string, any> = { prefix };

delete props['class'];
const slots = {};
const slots: Record<string, any> = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = React.createElement(StaticHtml, {
Expand Down Expand Up @@ -111,10 +124,11 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl
return { html, attrs };
}

/**
* @returns {Promise<[actionResult: any, actionKey: string, actionName: string] | undefined>}
*/
async function getFormState({ result }) {
async function getFormState({
result,
}: RendererContext): Promise<
[actionResult: any, actionKey: string, actionName: string] | undefined
> {
const { request, actionResult } = result;

if (!actionResult) return undefined;
Expand All @@ -139,7 +153,7 @@ async function getFormState({ result }) {
return [actionResult, actionKey, actionName];
}

async function renderToPipeableStreamAsync(vnode, options) {
async function renderToPipeableStreamAsync(vnode: any, options: Record<string, any>) {
const Writable = await getNodeWritable();
let html = '';
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -171,7 +185,7 @@ async function renderToPipeableStreamAsync(vnode, options) {
* Use a while loop instead of "for await" due to cloudflare and Vercel Edge issues
* See https://github.com/facebook/react/issues/24169
*/
async function readResult(stream) {
async function readResult(stream: ReactDOM.ReactDOMServerReadableStream) {
const reader = stream.getReader();
let result = '';
const decoder = new TextDecoder('utf-8');
Expand All @@ -191,13 +205,13 @@ async function readResult(stream) {
}
}

async function renderToReadableStreamAsync(vnode, options) {
async function renderToReadableStreamAsync(vnode: any, options: Record<string, any>) {
return await readResult(await ReactDOM.renderToReadableStream(vnode, options));
}

const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];

function isFormRequest(contentType) {
function isFormRequest(contentType: string | null) {
// Split off parameters like charset or boundary
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms
const type = contentType?.split(';')[0].toLowerCase();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { createElement as h } from 'react';
* As a bonus, we can signal to React that this subtree is
* entirely static and will never change via `shouldComponentUpdate`.
*/
const StaticHtml = ({ value, name, hydrate = true }) => {
const StaticHtml = ({
value,
name,
hydrate = true,
}: { value: string | null; name?: string; hydrate?: boolean }) => {
if (!value) return null;
const tagName = hydrate ? 'astro-slot' : 'astro-static-slot';
return h(tagName, {
Expand Down
4 changes: 4 additions & 0 deletions packages/integrations/react/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { SSRResult } from 'astro';
export type RendererContext = {
result: SSRResult;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { Fragment, createElement } from 'react';
import { DOCUMENT_NODE, ELEMENT_NODE, TEXT_NODE, parse } from 'ultrahtml';

let ids = 0;
export default function convert(children) {
export default function convert(children: any) {
let doc = parse(children.toString().trim());
let id = ids++;
let key = 0;

function createReactElementFromNode(node) {
function createReactElementFromNode(node: any) {
const childVnodes =
Array.isArray(node.children) && node.children.length
? node.children.map((child) => createReactElementFromNode(child)).filter(Boolean)
? node.children.map((child: any) => createReactElementFromNode(child)).filter(Boolean)
: undefined;

if (node.type === DOCUMENT_NODE) {
Expand Down
Loading