├── jsx.js
├── typings.json
├── .gitignore
├── src
├── lib
│ ├── polyfills.js
│ ├── constants.js
│ ├── client.js
│ ├── chunked.js
│ └── util.js
├── stream.d.ts
├── index.d.ts
├── stream-node.d.ts
├── jsx.d.ts
├── internal.d.ts
├── stream.js
├── stream-node.js
├── jsx.js
├── pretty.js
└── index.js
├── vitest.config.ts
├── .editorconfig
├── demo
├── index.html
├── src
│ ├── entry-client.jsx
│ ├── entry-server.jsx
│ ├── Pokemons.jsx
│ └── App.jsx
├── package.json
└── vite.config.js
├── .changeset
├── config.json
└── README.md
├── config
├── node-13-exports.js
├── node-commonjs.js
└── node-verify-exports.js
├── test
├── signals
│ └── render.test.jsx
├── index.test.jsx
├── debug
│ └── index.test.jsx
├── shallowRender.test.jsx
├── compat
│ ├── index.test.jsx
│ ├── stream-node.test.jsx
│ ├── stream.test.jsx
│ ├── render-chunked.test.jsx
│ └── async.test.jsx
├── context.test.jsx
├── jsx.test.jsx
├── pretty.test.jsx
└── utils.jsx
├── .github
└── workflows
│ ├── ci.yml
│ └── release.yml
├── tsconfig.json
├── jsx.d.ts
├── benchmarks
├── stack.js
├── isomorphic-ui
│ ├── search-results
│ │ ├── index.js
│ │ ├── data.js
│ │ ├── SearchResultsItem.js
│ │ └── Footer.js
│ ├── color-picker.js
│ └── _colors.js
├── async.js
├── index.js
├── text.js
└── lib
│ └── benchmark-lite.js
├── LICENSE
├── README.md
├── package.json
└── CHANGELOG.md
/jsx.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./dist/jsx');
2 |
--------------------------------------------------------------------------------
/typings.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "preact-render-to-string",
3 | "main": "src/index.d.ts",
4 | "version": false
5 | }
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /dist
2 | /node_modules
3 | /npm-debug.log
4 | .DS_Store
5 | /src/preact-render-to-string-tests.d.ts
6 | /benchmarks/.v8.modern.js
7 | /demo/node_modules
--------------------------------------------------------------------------------
/src/lib/polyfills.js:
--------------------------------------------------------------------------------
1 | if (typeof Symbol !== 'function') {
2 | let c = 0;
3 | // oxlint-disable-next-line no-global-assign
4 | Symbol = function (s) {
5 | return `@@${s}${++c}`;
6 | };
7 | Symbol.for = (s) => `@@${s}`;
8 | }
9 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | esbuild: {
5 | jsx: 'transform',
6 | jsxFactory: 'h',
7 | jsxFragment: 'Fragment',
8 | jsxDev: false
9 | },
10 | test: {}
11 | });
12 |
--------------------------------------------------------------------------------
/src/stream.d.ts:
--------------------------------------------------------------------------------
1 | import { VNode } from 'preact';
2 |
3 | interface RenderStream extends ReadableStream {
4 | allReady: Promise;
5 | }
6 |
7 | export function renderToReadableStream(
8 | vnode: VNode
,
9 | context?: any
10 | ): RenderStream;
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [{*.json,.*rc,*.yml}]
11 | indent_style = space
12 | indent_size = 2
13 | insert_final_newline = false
14 |
15 | [*.md]
16 | trim_trailing_whitespace = false
17 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Vite App
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@1.6.2/schema.json",
3 | "changelog": [
4 | "@changesets/changelog-github",
5 | { "repo": "preactjs/preact-render-to-string" }
6 | ],
7 | "commit": false,
8 | "linked": [],
9 | "access": "public",
10 | "baseBranch": "main",
11 | "updateInternalDependencies": "patch",
12 | "ignore": []
13 | }
14 |
--------------------------------------------------------------------------------
/config/node-13-exports.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 |
3 | const copy = (filename) => {
4 | // Copy .module.js --> .mjs for Node 13 compat.
5 | fs.writeFileSync(
6 | `${process.cwd()}/dist/${filename}.mjs`,
7 | fs.readFileSync(`${process.cwd()}/dist/${filename}.module.js`)
8 | );
9 | };
10 |
11 | copy('index');
12 | copy('jsx/index');
13 | copy('stream/index');
14 | copy('stream/node/index');
15 |
--------------------------------------------------------------------------------
/test/signals/render.test.jsx:
--------------------------------------------------------------------------------
1 | import render from '../../src/index.js';
2 | import { signal } from '@preact/signals-core';
3 | import { h } from 'preact';
4 | import { expect, describe, it } from 'vitest';
5 |
6 | /** @jsx h */
7 |
8 | describe('signals', () => {
9 | it('should render signals', () => {
10 | const disabled = signal(false);
11 |
12 | const vdom = ;
13 |
14 | expect(render(vdom)).to.equal(' ');
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/src/index.d.ts:
--------------------------------------------------------------------------------
1 | import { VNode } from 'preact';
2 |
3 | export default function renderToString(
4 | vnode: VNode
,
5 | context?: any
6 | ): string;
7 |
8 | export function render
(vnode: VNode
, context?: any): string;
9 | export function renderToString
(vnode: VNode
, context?: any): string;
10 | export function renderToStringAsync
(
11 | vnode: VNode
,
12 | context?: any
13 | ): string | Promise;
14 | export function renderToStaticMarkup(
15 | vnode: VNode
,
16 | context?: any
17 | ): string;
18 |
--------------------------------------------------------------------------------
/src/stream-node.d.ts:
--------------------------------------------------------------------------------
1 | import { VNode } from 'preact';
2 | import { WritableStream } from 'node:stream';
3 |
4 | interface RenderToPipeableStreamOptions {
5 | onShellReady?: () => void;
6 | onAllReady?: () => void;
7 | onError?: (error: any) => void;
8 | }
9 |
10 | interface PipeableStream {
11 | abort: (reason?: unknown) => void;
12 | pipe: (writable: WritableStream) => void;
13 | }
14 |
15 | export function renderToPipeableStream
(
16 | vnode: VNode
,
17 | options: RenderToPipeableStreamOptions,
18 | context?: any
19 | ): PipeableStream;
20 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | workflow_dispatch:
5 | pull_request:
6 | branches:
7 | - '**'
8 | push:
9 | branches:
10 | - main
11 |
12 | jobs:
13 | build_test:
14 | name: Build & Test
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v4
18 | - uses: actions/setup-node@v4
19 | with:
20 | node-version: 22
21 | cache: npm
22 |
23 | - name: Update npm
24 | run: npm install -g npm@latest
25 |
26 | - run: npm ci
27 | - name: test
28 | run: npm run test
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "lib": [
5 | "es6",
6 | "dom"
7 | ],
8 | "strict": true,
9 | "strictNullChecks": true,
10 | "strictFunctionTypes": true,
11 | "baseUrl": "../",
12 | "typeRoots": [
13 | "../"
14 | ],
15 | "jsx": "react",
16 | "jsxFactory": "h",
17 | "types": [],
18 | "noEmit": true,
19 | "forceConsistentCasingInFileNames": true
20 | },
21 | "files": [
22 | "src/index.d.ts"
23 | ]
24 | }
--------------------------------------------------------------------------------
/jsx.d.ts:
--------------------------------------------------------------------------------
1 | import { VNode } from 'preact';
2 |
3 | interface Options {
4 | jsx?: boolean;
5 | xml?: boolean;
6 | pretty?: boolean | string;
7 | shallow?: boolean;
8 | functions?: boolean;
9 | functionNames?: boolean;
10 | skipFalseAttributes?: boolean;
11 | }
12 |
13 | export default function renderToStringPretty(
14 | vnode: VNode,
15 | context?: any,
16 | options?: Options
17 | ): string;
18 | export function render(vnode: VNode, context?: any, options?: Options): string;
19 |
20 | export function shallowRender(
21 | vnode: VNode,
22 | context?: any,
23 | options?: Options
24 | ): string;
25 |
--------------------------------------------------------------------------------
/src/jsx.d.ts:
--------------------------------------------------------------------------------
1 | import { VNode } from 'preact';
2 |
3 | interface Options {
4 | jsx?: boolean;
5 | xml?: boolean;
6 | pretty?: boolean | string;
7 | shallow?: boolean;
8 | functions?: boolean;
9 | functionNames?: boolean;
10 | skipFalseAttributes?: boolean;
11 | }
12 |
13 | export default function renderToStringPretty(
14 | vnode: VNode,
15 | context?: any,
16 | options?: Options
17 | ): string;
18 | export function render(vnode: VNode, context?: any, options?: Options): string;
19 |
20 | export function shallowRender(
21 | vnode: VNode,
22 | context?: any,
23 | options?: Options
24 | ): string;
25 |
--------------------------------------------------------------------------------
/src/lib/constants.js:
--------------------------------------------------------------------------------
1 | // Options hooks
2 | export const DIFF = '__b';
3 | export const RENDER = '__r';
4 | export const DIFFED = 'diffed';
5 | export const COMMIT = '__c';
6 | export const SKIP_EFFECTS = '__s';
7 | export const CATCH_ERROR = '__e';
8 |
9 | // VNode properties
10 | export const COMPONENT = '__c';
11 | export const CHILDREN = '__k';
12 | export const PARENT = '__';
13 | export const MASK = '__m';
14 |
15 | // Component properties
16 | export const VNODE = '__v';
17 | export const DIRTY = '__d';
18 | export const BITS = '__g';
19 | export const NEXT_STATE = '__s';
20 | export const CHILD_DID_SUSPEND = '__c';
21 |
--------------------------------------------------------------------------------
/benchmarks/stack.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | function Leaf() {
4 | return (
5 |
6 |
7 | deep stack
8 |
9 |
10 | );
11 | }
12 |
13 | function PassThrough(props) {
14 | return {props.children}
;
15 | }
16 |
17 | function recursive(n) {
18 | if (n <= 0) {
19 | return ;
20 | }
21 | return {recursive(n - 1)} ;
22 | }
23 |
24 | const content = [];
25 | for (let i = 0; i < 10; i++) {
26 | content.push(recursive(1000));
27 | }
28 |
29 | export default function App() {
30 | return {content}
;
31 | }
32 |
--------------------------------------------------------------------------------
/demo/src/entry-client.jsx:
--------------------------------------------------------------------------------
1 | import { hydrate } from 'preact';
2 | import { App } from './App';
3 |
4 | const config = { attributes: true, childList: true, subtree: true };
5 | const mut = new MutationObserver((mutationList) => {
6 | for (const mutation of mutationList) {
7 | if (mutation.type === 'childList') {
8 | console.log('A child node has been added or removed.', mutation);
9 | } else if (mutation.type === 'attributes') {
10 | console.log(
11 | `The ${mutation.attributeName} attribute was modified.`,
12 | mutation
13 | );
14 | }
15 | }
16 | });
17 | mut.observe(document, config);
18 |
19 | hydrate( , document);
20 |
--------------------------------------------------------------------------------
/benchmarks/isomorphic-ui/search-results/index.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 | import { SearchResultsItem } from './SearchResultsItem';
3 | import { Footer } from './Footer';
4 | import { getNextSearchResults } from './data';
5 |
6 | export class App extends Component {
7 | componentDidMount() {
8 | window.onMount();
9 | }
10 |
11 | render() {
12 | const searchResultsData = getNextSearchResults();
13 |
14 | return (
15 |
16 |
17 | {searchResultsData.items.map((item) => {
18 | return ;
19 | })}
20 |
21 |
22 |
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/benchmarks/isomorphic-ui/search-results/data.js:
--------------------------------------------------------------------------------
1 | import searchResultsData from './search-results-data.json';
2 |
3 | function performSearch(input) {
4 | const pageIndex = input.pageIndex || 0;
5 | const pageSize = 100;
6 | const start = pageIndex * pageSize;
7 |
8 | const items = [];
9 |
10 | for (let i = start; i < start + pageSize; i++) {
11 | items.push(searchResultsData.items[i % searchResultsData.items.length]);
12 | }
13 |
14 | const results = {
15 | pageIndex: pageIndex,
16 | totalMatches: searchResultsData.items.length,
17 | items: items
18 | };
19 |
20 | return results;
21 | }
22 |
23 | export function getNextSearchResults() {
24 | return performSearch({ pageIndex: 0 });
25 | }
26 |
--------------------------------------------------------------------------------
/test/index.test.jsx:
--------------------------------------------------------------------------------
1 | import renderToString from '../src/index.js';
2 | import { default as renderToStringPretty, shallowRender } from '../src/jsx.js';
3 | import { expect, describe, it } from 'vitest';
4 |
5 | describe('render-to-string', () => {
6 | describe('exports', () => {
7 | it('exposes renderToString as default', () => {
8 | expect(renderToString).to.be.a('function');
9 | });
10 | });
11 | });
12 |
13 | describe('render-to-string/jsx', () => {
14 | it('exposes renderToStringPretty as default export', () => {
15 | expect(renderToStringPretty).to.be.a('function');
16 | });
17 |
18 | it('exposes shallowRender as a named export', () => {
19 | expect(shallowRender).to.be.a('function');
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "demo",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "server.js",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "vite --force",
9 | "build": "npm run build:client && npm run build:server",
10 | "build:client": "vite build --outDir dist/client",
11 | "build:server": "vite build --ssr src/entry-server.jsx --outDir dist/server"
12 | },
13 | "author": "",
14 | "license": "ISC",
15 | "devDependencies": {
16 | "vite": "^4.1.4"
17 | },
18 | "dependencies": {
19 | "@preact/preset-vite": "^2.8.0",
20 | "express": "^4.18.2",
21 | "graphql": "^16.6.0",
22 | "preact": "^10.21.0",
23 | "urql": "latest"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/demo/src/entry-server.jsx:
--------------------------------------------------------------------------------
1 | import { renderToPipeableStream } from '../../src/stream-node';
2 | import { App } from './App';
3 |
4 | export function render({ res, head }) {
5 | res.socket.on('error', (error) => {
6 | console.error('Fatal', error);
7 | });
8 | const { pipe, abort } = renderToPipeableStream( , {
9 | onShellReady() {
10 | res.statusCode = 200;
11 | res.setHeader('Content-type', 'text/html');
12 | pipe(res);
13 | },
14 | onErrorShell(error) {
15 | res.statusCode = 500;
16 | res.send(
17 | `An error ocurred:
${error.message} `
18 | );
19 | }
20 | });
21 |
22 | // Abandon and switch to client rendering if enough time passes.
23 | // Try lowering this to see the client recover.
24 | setTimeout(abort, 20000);
25 | }
26 |
--------------------------------------------------------------------------------
/demo/src/Pokemons.jsx:
--------------------------------------------------------------------------------
1 | import { gql, useQuery } from 'urql';
2 | import { h } from 'preact';
3 |
4 | const POKEMONS_QUERY = gql`
5 | query Pokemons($limit: Int!) {
6 | pokemons(limit: $limit) {
7 | id
8 | name
9 | }
10 | }
11 | `;
12 |
13 | const Counter = () => {
14 | const [result] = useQuery({
15 | query: POKEMONS_QUERY,
16 | variables: { limit: 10 }
17 | });
18 |
19 | const { data, fetching, error } = result;
20 | console.log('hydrated!');
21 | return (
22 |
23 | {fetching &&
Loading...
}
24 |
25 | {error &&
Oh no... {error.message}
}
26 |
27 | {data && (
28 |
29 | {data.pokemons.map((pokemon) => (
30 | {pokemon.name}
31 | ))}
32 |
33 | )}
34 |
35 | );
36 | };
37 |
38 | export default Counter;
39 |
--------------------------------------------------------------------------------
/src/internal.d.ts:
--------------------------------------------------------------------------------
1 | import { ComponentChildren, ComponentChild, VNode } from 'preact';
2 |
3 | interface Suspended {
4 | id: string;
5 | promise: Promise;
6 | context: any;
7 | isSvgMode: boolean;
8 | selectValue: any;
9 | vnode: VNode;
10 | parent: VNode | null;
11 | }
12 |
13 | interface RendererErrorHandler {
14 | (
15 | this: RendererState,
16 | error: any,
17 | vnode: VNode<{ fallback: any }>,
18 | renderChild: (child: ComponentChildren, parent: ComponentChild) => string
19 | ): string | undefined;
20 | }
21 |
22 | interface RendererState {
23 | start: number;
24 | suspended: Suspended[];
25 | abortSignal?: AbortSignal | undefined;
26 | onWrite: (str: string) => void;
27 | onError?: RendererErrorHandler;
28 | }
29 |
30 | interface RenderToChunksOptions {
31 | context?: any;
32 | onError?: (error: any) => void;
33 | onWrite: (str: string) => void;
34 | abortSignal?: AbortSignal;
35 | }
36 |
--------------------------------------------------------------------------------
/benchmarks/async.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { lazy } from 'preact/compat';
3 |
4 | function Leaf() {
5 | return (
6 |
7 |
8 | deep stack
9 |
10 |
11 | );
12 | }
13 |
14 | // oxlint-disable-next-line no-new-array
15 | const lazies = new Array(600).fill(600).map(() =>
16 | lazy(() =>
17 | Promise.resolve().then(() => ({
18 | default: (props) => {props.children}
19 | }))
20 | )
21 | );
22 | function PassThrough(props) {
23 | const Lazy = lazies(props.id);
24 | return ;
25 | }
26 |
27 | function recursive(n, m) {
28 | if (n <= 0) {
29 | return ;
30 | }
31 | return {recursive(n - 1)} ;
32 | }
33 |
34 | const content = [];
35 | for (let i = 0; i < 5; i++) {
36 | content.push(recursive(10, i));
37 | }
38 |
39 | export default function App() {
40 | return {content}
;
41 | }
42 |
--------------------------------------------------------------------------------
/test/debug/index.test.jsx:
--------------------------------------------------------------------------------
1 | import 'preact/debug';
2 | import render from '../../src/index.js';
3 | import { h } from 'preact';
4 | import { expect, describe, it } from 'vitest';
5 |
6 | describe('debug', () => {
7 | it('should not throw "Objects are not valid as a child" error', () => {
8 | expect(() => render({'foo'}
)).not.to.throw();
9 | expect(() => render({2}
)).not.to.throw();
10 | expect(() => render({true}
)).not.to.throw();
11 | expect(() => render({false}
)).not.to.throw();
12 | expect(() => render({null}
)).not.to.throw();
13 | expect(() => render({undefined}
)).not.to.throw();
14 | });
15 |
16 | it('should not throw "Objects are not valid as a child" error #2', () => {
17 | function Str() {
18 | return ['foo'];
19 | }
20 | expect(() => render( )).not.to.throw();
21 | });
22 |
23 | it('should not throw "Objects are not valid as a child" error #3', () => {
24 | expect(() => render({'foo'}bar
)).not.to.throw();
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/demo/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { Suspense, lazy } from 'preact/compat';
3 | import { Client, Provider, cacheExchange, fetchExchange } from 'urql';
4 |
5 | const client = new Client({
6 | url: 'https://trygql.formidable.dev/graphql/basic-pokedex',
7 | exchanges: [cacheExchange, fetchExchange],
8 | suspense: true
9 | });
10 |
11 | export function App({ head }) {
12 | const Pokemons = lazy(
13 | () =>
14 | new Promise((res) => {
15 | setTimeout(
16 | () => {
17 | res(import('./Pokemons.jsx'));
18 | },
19 | typeof document === 'undefined' ? 500 : 3000
20 | );
21 | })
22 | );
23 | return (
24 |
25 |
26 |
27 |
28 |
29 | Our Counter application
30 | Loading...
}>
31 |
32 |
33 |
34 | {import.meta.env.DEV && (
35 |
36 | )}
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Jason Miller
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | release:
10 | name: Release
11 | runs-on: ubuntu-latest
12 | permissions:
13 | contents: write
14 | id-token: write
15 | issues: read
16 | pull-requests: write
17 | steps:
18 | - name: Checkout Repo
19 | uses: actions/checkout@v4
20 | with:
21 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
22 | fetch-depth: 0
23 |
24 | - name: Setup Node.js 22.x
25 | uses: actions/setup-node@v4
26 | with:
27 | node-version: 22
28 | cache: npm
29 | registry-url: "https://registry.npmjs.org"
30 |
31 | - name: Update npm
32 | run: npm install -g npm@latest
33 |
34 | - name: Install Dependencies
35 | run: npm ci
36 |
37 | - name: Create Release PR or Publish
38 | uses: changesets/action@v1.5.3
39 | with:
40 | publish: npx changeset publish
41 | env:
42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43 |
--------------------------------------------------------------------------------
/src/stream.js:
--------------------------------------------------------------------------------
1 | import { Deferred } from './lib/util.js';
2 | import { renderToChunks } from './lib/chunked.js';
3 |
4 | /** @typedef {ReadableStream & { allReady: Promise}} RenderStream */
5 |
6 | /**
7 | * @param {import('preact').VNode} vnode
8 | * @param {any} [context]
9 | * @returns {RenderStream}
10 | */
11 | export function renderToReadableStream(vnode, context) {
12 | /** @type {Deferred} */
13 | const allReady = new Deferred();
14 | const encoder = new TextEncoder('utf-8');
15 |
16 | /** @type {RenderStream} */
17 | const stream = new ReadableStream({
18 | start(controller) {
19 | renderToChunks(vnode, {
20 | context,
21 | onError: (error) => {
22 | allReady.reject(error);
23 | controller.abort(error);
24 | },
25 | onWrite(s) {
26 | controller.enqueue(encoder.encode(s));
27 | }
28 | })
29 | .then(() => {
30 | controller.close();
31 | allReady.resolve();
32 | })
33 | .catch((error) => {
34 | controller.error(error);
35 | allReady.reject(error);
36 | });
37 | }
38 | });
39 |
40 | stream.allReady = allReady.promise;
41 |
42 | return stream;
43 | }
44 |
--------------------------------------------------------------------------------
/config/node-commonjs.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 |
4 | // This file will only export default exports in commonjs bundles
5 | // instead of guarding them behind a `.default` property.
6 |
7 | const filePath = (file) => path.join(process.cwd(), 'dist', file);
8 |
9 | // Main entry
10 | fs.copyFileSync(filePath('index.js'), filePath('commonjs.js'));
11 |
12 | const source = [
13 | `const mod = require('./commonjs');`,
14 | `mod.default.renderToStringAsync = mod.renderToStringAsync;`,
15 | `mod.default.renderToStaticMarkup = mod.default;`,
16 | `mod.default.renderToString = mod.default;`,
17 | `mod.default.render = mod.default;`,
18 | `module.exports = mod.default;`
19 | ].join('\n');
20 | fs.writeFileSync(filePath('index.js'), source, 'utf-8');
21 |
22 | // JSX entry
23 | fs.copyFileSync(filePath('jsx/index.js'), filePath('jsx/commonjs.js'));
24 |
25 | const sourceJsx = [
26 | `const entry = require('./commonjs');`,
27 | `entry.default.render = entry.render;`,
28 | `entry.default.shallowRender = entry.shallowRender;`,
29 | `module.exports = entry.default;`
30 | ].join('\n');
31 | fs.writeFileSync(filePath('jsx/index.js'), sourceJsx, 'utf-8');
32 |
--------------------------------------------------------------------------------
/benchmarks/isomorphic-ui/color-picker.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { useState, useEffect } from 'preact/hooks';
3 | import colors from './_colors.js';
4 |
5 | export function App({ onMount }) {
6 | const [selectedColorIndex, setSelectedColorIndex] = useState(0);
7 | const selectedColor = colors[selectedColorIndex];
8 |
9 | useEffect(() => {
10 | if (onMount) {
11 | onMount(setSelectedColorIndex);
12 | }
13 | if (typeof window !== 'undefined') window.onMount();
14 | }, []);
15 |
16 | return (
17 |
18 |
Choose your favorite color:
19 |
20 | {colors.length ? (
21 |
22 | {colors.map((color, i) => (
23 | setSelectedColorIndex(i)}
32 | >
33 | {color.name}
34 |
35 | ))}
36 |
37 | ) : (
38 |
No colors!
39 | )}
40 |
41 |
42 | You chose:
43 |
{selectedColor.name}
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/config/node-verify-exports.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const assert = require('assert/strict');
3 |
4 | const filePath = (file) => path.join(process.cwd(), 'dist', file);
5 |
6 | // Main CJS
7 | const mainCjs = require(filePath('index.js'));
8 | assert(typeof mainCjs === 'function');
9 | assert(typeof mainCjs.renderToString === 'function');
10 | assert(typeof mainCjs.renderToStaticMarkup === 'function');
11 | assert(typeof mainCjs.render === 'function');
12 |
13 | // Main ESM
14 | (async () => {
15 | const mainESM = await import(filePath('index.mjs'));
16 | assert(typeof mainESM.default === 'function');
17 | assert(typeof mainESM.renderToString === 'function');
18 | assert(typeof mainESM.renderToStaticMarkup === 'function');
19 | assert(typeof mainESM.render === 'function');
20 | })();
21 |
22 | // JSX CJS
23 | const jsxCjs = require(filePath('jsx/index.js'));
24 | assert(typeof jsxCjs === 'function');
25 | assert(typeof jsxCjs.render === 'function');
26 | assert(typeof jsxCjs.shallowRender === 'function');
27 |
28 | // JSX ESM
29 | (async () => {
30 | const jsxESM = await import(filePath('jsx/index.mjs'));
31 | assert(typeof jsxESM.default === 'function');
32 | assert(typeof jsxESM.render === 'function');
33 | assert(typeof jsxESM.shallowRender === 'function');
34 | })();
35 |
--------------------------------------------------------------------------------
/benchmarks/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import Suite from 'benchmarkjs-pretty';
3 | import renderToStringBaseline, {
4 | renderToStringAsync as renderToStringAsyncBaseline
5 | } from 'baseline-rts';
6 | // import renderToString from '../src/index';
7 | import renderToString, { renderToStringAsync } from '../dist/index.module.js';
8 | import TextApp from './text';
9 | import StackApp from './stack';
10 | import { App as IsomorphicSearchResults } from './isomorphic-ui/search-results/index';
11 | import { App as ColorPicker } from './isomorphic-ui/color-picker';
12 |
13 | function suite(name, Root) {
14 | return new Suite(name)
15 | .add('baseline', () => renderToStringAsyncBaseline( ))
16 | .add('current', () => renderToStringAsync( ))
17 | .run();
18 | }
19 |
20 | function asyncSuite(name, Root) {
21 | return new Suite(name)
22 | .add('baseline', () => renderToStringBaseline( ))
23 | .add('current', () => renderToString( ))
24 | .run();
25 | }
26 |
27 | (async () => {
28 | await suite('Text', TextApp);
29 | await suite('SearchResults', IsomorphicSearchResults);
30 | await suite('ColorPicker', ColorPicker);
31 | await suite('Stack Depth', StackApp);
32 |
33 | const { App: Async } = await import('./async.js');
34 | await asyncSuite('async', Async);
35 | })();
36 |
--------------------------------------------------------------------------------
/benchmarks/isomorphic-ui/search-results/SearchResultsItem.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 |
3 | export class SearchResultsItem extends Component {
4 | constructor(props) {
5 | super(props);
6 |
7 | this.state = {
8 | purchased: false,
9 | item: this.props.item
10 | };
11 |
12 | this.handleBuyButtonClick = this.handleBuyButtonClick.bind(this);
13 | }
14 |
15 | componentWillReceiveProps() {
16 | this.state = {
17 | purchased: false
18 | };
19 | }
20 |
21 | handleBuyButtonClick() {
22 | this.setState({ purchased: true });
23 | }
24 |
25 | render() {
26 | var item = this.props.item;
27 | var style = { backgroundColor: this.state.purchased ? '#f1c40f' : '' };
28 |
29 | return (
30 |
31 |
{item.title}
32 |
39 |
40 |
{item.price}
41 |
42 | {this.state.purchased ? (
43 |
Purchased!
44 | ) : (
45 |
50 | Buy now!
51 |
52 | )}
53 |
54 | );
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/demo/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import preact from '@preact/preset-vite';
3 | import path from 'path';
4 | import { promises as fs } from 'fs';
5 |
6 | function ssrPlugin() {
7 | return {
8 | name: 'ssrPlugin',
9 |
10 | configureServer(server) {
11 | server.middlewares.use(async (req, res, next) => {
12 | if (req.url !== '/') {
13 | return next();
14 | }
15 |
16 | const { render } = await server.ssrLoadModule(
17 | path.resolve(__dirname, './src/entry-server')
18 | );
19 |
20 | // setTimeout(abort, 10000);
21 |
22 | const indexHtml = await fs.readFile(
23 | path.resolve(__dirname, './index.html'),
24 | 'utf-8'
25 | );
26 |
27 | const url = new URL('http://localhost:5173/' + req.url);
28 | const template = await server.transformIndexHtml(
29 | url.toString(),
30 | indexHtml
31 | );
32 |
33 | const head = template.match(/(.+?)<\/head>/s)[1];
34 |
35 | return render({ res, head });
36 | });
37 | }
38 | };
39 | }
40 |
41 | export default defineConfig({
42 | // @ts-ignore
43 | ssr: {
44 | noExternal: /./
45 | },
46 | build: {
47 | ssrManifest: true,
48 | commonjsOptions: {
49 | transformMixedEsModules: true
50 | }
51 | },
52 | resolve: {
53 | alias: {
54 | preact: path.resolve(__dirname, './node_modules/preact'),
55 | 'preact/compat': path.resolve(__dirname, './node_modules/preact/compat'),
56 | 'preact/hooks': path.resolve(__dirname, './node_modules/preact/hooks')
57 | }
58 | },
59 | plugins: [ssrPlugin(), preact()]
60 | });
61 |
--------------------------------------------------------------------------------
/test/shallowRender.test.jsx:
--------------------------------------------------------------------------------
1 | import { shallowRender } from '../src/jsx.js';
2 | import { h, Fragment } from 'preact';
3 | import { vi, describe, it, expect } from 'vitest';
4 |
5 | describe('shallowRender()', () => {
6 | it('should not render nested components', () => {
7 | let Test = vi.fn(({ foo, children }) => (
8 |
9 | test child
10 | {children}
11 |
12 | ));
13 | Test.displayName = 'Test';
14 |
15 | let rendered = shallowRender(
16 | ,
21 | null,
22 | { pretty: false, jsx: false }
23 | );
24 |
25 | expect(rendered).to.equal(
26 | ``
27 | );
28 | expect(Test).not.toHaveBeenCalled();
29 | });
30 |
31 | it('should always render root component', () => {
32 | let Test = vi.fn(({ foo, children }) => (
33 |
34 | test child
35 | {children}
36 |
37 | ));
38 | Test.displayName = 'Test';
39 |
40 | let rendered = shallowRender(
41 |
42 | asdf
43 | ,
44 | null,
45 | { pretty: false, jsx: false }
46 | );
47 |
48 | expect(rendered).to.equal(
49 | `test child asdf
`
50 | );
51 | expect(Test).toHaveBeenCalledTimes(1);
52 | });
53 |
54 | describe('should ignore Fragments', () => {
55 | it('passed directly', () => {
56 | let rendered = shallowRender(
57 |
58 | foo
59 |
60 | );
61 | expect(rendered).to.equal(`foo
`);
62 | });
63 |
64 | it('passed from FC', () => {
65 | const Test = () => (
66 |
67 | foo
68 |
69 | );
70 |
71 | let rendered = shallowRender( );
72 |
73 | expect(rendered).to.equal(`foo
`);
74 | });
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/test/compat/index.test.jsx:
--------------------------------------------------------------------------------
1 | import render from '../../src/index.js';
2 | import { createElement as h, Component } from 'preact/compat';
3 | import { expect, describe, it } from 'vitest';
4 |
5 | describe('compat', () => {
6 | it('should not duplicate class attribute when className is empty', async () => {
7 | let rendered = render(h('div', { className: '' }));
8 | let expected = `
`;
9 |
10 | expect(rendered).to.equal(expected);
11 | });
12 |
13 | it('should apply defaultProps to class components', () => {
14 | class Test extends Component {
15 | render(props) {
16 | return
;
17 | }
18 | }
19 | Test.defaultProps = {
20 | foo: 'default foo',
21 | bar: 'default bar'
22 | };
23 |
24 | expect(render( ), 'defaults').to.equal(
25 | '
'
26 | );
27 | expect(render( ), 'partial').to.equal(
28 | '
'
29 | );
30 | expect(render( ), 'overridden').to.equal(
31 | '
'
32 | );
33 | expect(render( ), 'overridden').to.equal(
34 | '
'
35 | );
36 | });
37 |
38 | it('should apply defaultProps to functional components', () => {
39 | const Test = (props) =>
;
40 | Test.defaultProps = {
41 | foo: 'default foo',
42 | bar: 'default bar'
43 | };
44 |
45 | expect(render( ), 'defaults').to.equal(
46 | '
'
47 | );
48 | expect(render( ), 'partial').to.equal(
49 | '
'
50 | );
51 | expect(render( ), 'overridden').to.equal(
52 | '
'
53 | );
54 | expect(render( ), 'overridden').to.equal(
55 | '
'
56 | );
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/src/stream-node.js:
--------------------------------------------------------------------------------
1 | import { PassThrough } from 'node:stream';
2 | import { renderToChunks } from './lib/chunked.js';
3 |
4 | /**
5 | * @typedef {object} RenderToPipeableStreamOptions
6 | * @property {() => void} [onShellReady]
7 | * @property {() => void} [onAllReady]
8 | * @property {(error) => void} [onError]
9 | */
10 |
11 | /**
12 | * @typedef {object} PipeableStream
13 | * @property {() => void} abort
14 | * @property {(writable: import('stream').Writable) => void} pipe
15 | */
16 |
17 | /**
18 | * @param {import('preact').VNode} vnode
19 | * @param {RenderToPipeableStreamOptions} options
20 | * @param {any} [context]
21 | * @returns {PipeableStream}
22 | */
23 | export function renderToPipeableStream(vnode, options, context) {
24 | const encoder = new TextEncoder('utf-8');
25 |
26 | const controller = new AbortController();
27 | const stream = new PassThrough();
28 |
29 | renderToChunks(vnode, {
30 | context,
31 | abortSignal: controller.signal,
32 | onError: (error) => {
33 | if (options.onError) {
34 | options.onError(error);
35 | }
36 | controller.abort(error);
37 | },
38 | onWrite(s) {
39 | stream.write(encoder.encode(s));
40 | }
41 | })
42 | .then(() => {
43 | options.onAllReady && options.onAllReady();
44 | stream.end();
45 | })
46 | .catch((error) => {
47 | stream.destroy();
48 | if (options.onError) {
49 | options.onError(error);
50 | } else {
51 | throw error;
52 | }
53 | });
54 |
55 | Promise.resolve().then(() => {
56 | options.onShellReady && options.onShellReady();
57 | });
58 |
59 | return {
60 | /**
61 | * @param {unknown} [reason]
62 | */
63 | abort(
64 | reason = new Error(
65 | 'The render was aborted by the server without a reason.'
66 | )
67 | ) {
68 | // Remix/React-Router will always call abort after a timeout, even on success
69 | if (stream.closed) return;
70 |
71 | controller.abort();
72 | stream.destroy();
73 | if (options.onError) {
74 | options.onError(reason);
75 | }
76 | },
77 | /**
78 | * @param {import("stream").Writable} writable
79 | */
80 | pipe(writable) {
81 | stream.pipe(writable, { end: true });
82 | }
83 | };
84 | }
85 |
--------------------------------------------------------------------------------
/benchmarks/text.js:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 |
3 | function Bavaria() {
4 | return (
5 |
6 |
7 | Bavaria ipsum dolor sit amet gwiss Charivari Auffisteign koa. Umma
8 | pfenningguat vui huift vui back mas Landla Bradwurschtsemmal,
9 | Fingahaggln. Wolpern ja, wo samma denn wea nia ausgähd, kummt nia hoam
10 | baddscher i moan oiwei! Kloan pfenningguat is Charivari Bussal,
11 | hallelujah sog i, luja. Liberalitas Bavariae hod Schorsch om auf’n Gipfe
12 | gwiss naa. Und ja, wo samma denn Ohrwaschl hoggd auffi Spotzerl
13 | Diandldrahn, oba? Is sog i und glei wirds no fui lustiga Biaschlegl ma
14 | nimma ned woar gscheckate, pfenningguat! Gstanzl dei Schorsch Radi i mog
15 | di fei hea Reiwadatschi fensdaln dei glei a Hoiwe. Bitt umananda ghupft
16 | wia gsprunga Gschicht kimmt, oamoi obandeln. Sog i helfgod amoi
17 | hallelujah sog i, luja i hob di narrisch gean, Brodzeid. Wolln a Maß und
18 | no a Maß Gaudi obandln eana boarischer hallelujah sog i, luja Maßkruag
19 | greaßt eich nachad, Schmankal.
20 |
21 |
22 | Dei um Godds wujn naa Watschnbaam Obazda Trachtnhuat, Vergeltsgott
23 | Schneid Schbozal. Om auf’n Gipfe Ramasuri um Godds wujn eana. Wos
24 | sammawiedaguad sei Weißwiaschd da, hog di hi is des liab des umananda
25 | Brezn Sauakraud Diandldrahn. Vo de weida pfundig Kirwa de Sonn
26 | Hetschapfah Watschnpladdla auf gehds beim Schichtl Meidromml auffi lem
27 | und lem lossn! Watschnpladdla wolln measi obandeln griasd eich midnand
28 | Oachkatzlschwoaf is ma Wuascht sammawiedaguad aasgem. A so a Schmarn
29 | Weibaleid naa, des basd scho. Abfieseln helfgod Sauwedda middn ded
30 | schoo. A bissal wos gehd ollaweil Sauwedda is Servas wiavui wo hi o’ha,
31 | a liabs Deandl pfiad de nix. Maßkruag etza so spernzaln. Weiznglasl
32 | Bradwurschtsemmal da, Schdeckalfisch: Mei Musi bitt des wiad a
33 | Mordsgaudi kumm geh Biakriagal Greichats obacht?
34 |
35 |
36 | );
37 | }
38 |
39 | const content = [];
40 | for (let i = 0; i < 1000; i++) {
41 | content.push( );
42 | }
43 |
44 | export default function App() {
45 | return {content}
;
46 | }
47 |
--------------------------------------------------------------------------------
/benchmarks/lib/benchmark-lite.js:
--------------------------------------------------------------------------------
1 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2 |
3 | export default class Suite {
4 | constructor(name, { iterations = 10, timeLimit = 5000 } = {}) {
5 | this.name = name;
6 | this.iterations = iterations;
7 | this.timeLimit = timeLimit;
8 | this.tests = [];
9 | }
10 | add(name, executor) {
11 | this.tests.push({ name, executor });
12 | return this;
13 | }
14 | async run() {
15 | console.log(` ${this.name}:`);
16 | const results = [];
17 | let fastest = 0;
18 | for (const test of this.tests) {
19 | await sleep(50);
20 | for (let i = 0; i < 5; i++) test.executor(i);
21 | await sleep(10);
22 | const result = this.runOne(test);
23 | if (result.hz > fastest) fastest = result.hz;
24 | results.push({ ...test, ...result });
25 | }
26 | const winners = results.filter((r) => r.hz === fastest).map((r) => r.name);
27 | console.log(` Fastest: \x1b[32m${winners}\x1b[39m\n`);
28 | return this;
29 | }
30 | runOne({ name, executor }) {
31 | const { iterations, timeLimit } = this;
32 | let count = 5;
33 | let now = performance.now(),
34 | start = now,
35 | prev = now;
36 | const times = [];
37 | do {
38 | // oxlint-disable-next-line no-unused-vars
39 | for (let i = iterations; i--; ) executor(++count);
40 | prev = now;
41 | now = performance.now();
42 | times.push((now - prev) / iterations);
43 | } while (now - start < timeLimit);
44 | const elapsed = now - start;
45 | const hz = Math.round((count / elapsed) * 1000);
46 | const average = toFixed(elapsed / count);
47 | const middle = Math.floor(times.length / 2);
48 | const middle2 = Math.ceil(times.length / 2);
49 | times.sort((a, b) => a - b);
50 | const median = toFixed((times[middle] + times[middle2]) / 2);
51 | const hzStr = hz.toLocaleString();
52 | const averageStr = average.toLocaleString();
53 | const n = times.length;
54 | const stdev = Math.sqrt(
55 | times.reduce((c, t) => c + (t - average) ** 2, 0) / (n - 1)
56 | );
57 | const p95 = toFixed((1.96 * stdev) / Math.sqrt(n));
58 | console.log(
59 | ` \x1b[36m${name}:\x1b[0m ${hzStr}/s, average: ${averageStr}ms ±${p95} (median: ${median}ms)`
60 | );
61 | return { elapsed, hz, average, median };
62 | }
63 | }
64 |
65 | const toFixed = (v) => ((v * 100) | 0) / 100;
66 |
--------------------------------------------------------------------------------
/src/lib/client.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-var, key-spacing, object-curly-spacing, prefer-arrow-callback, semi, keyword-spacing */
2 |
3 | // function initPreactIslandElement() {
4 | // class PreactIslandElement extends HTMLElement {
5 | // connectedCallback() {
6 | // var d = this;
7 | // if (!d.isConnected) return;
8 |
9 | // let i = this.getAttribute('data-target');
10 | // if (!i) return;
11 |
12 | // var s,
13 | // e,
14 | // c = document.createNodeIterator(document, 128);
15 | // while (c.nextNode()) {
16 | // let n = c.referenceNode;
17 |
18 | // if (n.data == 'preact-island:' + i) s = n;
19 | // else if (n.data == '/preact-island:' + i) e = n;
20 | // if (s && e) break;
21 | // }
22 | // if (s && e) {
23 | // requestAnimationFrame(() => {
24 | // var p = e.previousSibling;
25 | // while (p != s) {
26 | // if (!p || p == s) break;
27 | // e.parentNode.removeChild(p);
28 | // p = e.previousSibling;
29 | // }
30 |
31 | // c = s;
32 | // while (d.firstChild) {
33 | // s = d.firstChild;
34 | // d.removeChild(s);
35 | // c.after(s);
36 | // c = s;
37 | // }
38 |
39 | // d.parentNode.removeChild(d);
40 | // });
41 | // }
42 | // }
43 | // }
44 |
45 | // customElements.define('preact-island', PreactIslandElement);
46 | // }
47 |
48 | // To modify the INIT_SCRIPT, uncomment the above code, modify it, and paste it into https://try.terser.org/.
49 | const INIT_SCRIPT = `class e extends HTMLElement{connectedCallback(){var e=this;if(!e.isConnected)return;let t=this.getAttribute("data-target");if(t){for(var r,a,i=document.createNodeIterator(document,128);i.nextNode();){let e=i.referenceNode;if(e.data=="preact-island:"+t?r=e:e.data=="/preact-island:"+t&&(a=e),r&&a)break}r&&a&&requestAnimationFrame((()=>{for(var t=a.previousSibling;t!=r&&t&&t!=r;)a.parentNode.removeChild(t),t=a.previousSibling;for(i=r;e.firstChild;)r=e.firstChild,e.removeChild(r),i.after(r),i=r;e.parentNode.removeChild(e)}))}}}customElements.define("preact-island",e);`;
50 |
51 | export function createInitScript() {
52 | return ``;
53 | }
54 |
55 | /**
56 | * @param {string} id
57 | * @param {string} content
58 | * @returns {string}
59 | */
60 | export function createSubtree(id, content) {
61 | return `${content} `;
62 | }
63 |
--------------------------------------------------------------------------------
/test/context.test.jsx:
--------------------------------------------------------------------------------
1 | import render from '../src/jsx.js';
2 | import { h, createContext, Component } from 'preact';
3 | import { expect, describe, it } from 'vitest';
4 | import { dedent } from './utils.jsx';
5 |
6 | describe('context', () => {
7 | let renderJsx = (jsx, opts) => render(jsx, null, opts).replace(/ {2}/g, '\t');
8 |
9 | it('should support class component as consumer', () => {
10 | const Ctx = createContext();
11 |
12 | class ClassConsumer extends Component {
13 | render() {
14 | const value = this.context;
15 | return ;
16 | }
17 | }
18 | ClassConsumer.contextType = Ctx;
19 |
20 | let rendered = renderJsx(
21 |
22 |
23 |
24 | );
25 |
26 | expect(rendered).to.equal(dedent`
27 |
28 | `);
29 | });
30 |
31 | it('should support createContext', () => {
32 | const { Provider, Consumer } = createContext();
33 | let rendered = renderJsx(
34 |
35 | {(value) => }
36 |
37 | );
38 |
39 | expect(rendered).to.equal(dedent`
40 |
41 | `);
42 | });
43 |
44 | it('should support nested Providers', () => {
45 | const { Provider, Consumer } = createContext();
46 | let rendered = renderJsx(
47 |
48 |
49 | {(value) => }
50 |
51 |
52 | );
53 |
54 | expect(rendered).to.equal(dedent`
55 |
56 | `);
57 | });
58 |
59 | it('should support falsy context value', () => {
60 | const { Provider, Consumer } = createContext();
61 | let rendered = renderJsx(
62 |
63 | {(value) => }
64 |
65 | );
66 |
67 | expect(rendered).to.equal(dedent`
68 |
69 | `);
70 | });
71 |
72 | it('should support default context value with absent provider', () => {
73 | const { Consumer } = createContext('correct');
74 | let rendered = renderJsx(
75 | {(value) => }
76 | );
77 |
78 | expect(rendered).to.equal(dedent`
79 |
80 | `);
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/test/compat/stream-node.test.jsx:
--------------------------------------------------------------------------------
1 | import { PassThrough } from 'node:stream';
2 | import { h } from 'preact';
3 | import { expect, describe, it } from 'vitest';
4 | import { Suspense } from 'preact/compat';
5 | import { createSubtree, createInitScript } from '../../src/lib/client';
6 | import { renderToPipeableStream } from '../../src/stream-node';
7 | import { Deferred } from '../../src/lib/util';
8 | import { createSuspender } from '../utils';
9 |
10 | function streamToString(stream) {
11 | const decoder = new TextDecoder();
12 | const def = new Deferred();
13 | stream.on('data', (chunk) => {
14 | chunks.push(decoder.decode(chunk));
15 | });
16 | stream.on('error', (err) => def.reject(err));
17 | stream.on('end', () => def.resolve(chunks));
18 | const chunks = [];
19 | return def;
20 | }
21 |
22 | /**
23 | * @param {ReadableStream} input
24 | */
25 | function createSink() {
26 | const stream = new PassThrough();
27 | const def = streamToString(stream);
28 |
29 | return {
30 | promise: def.promise,
31 | stream
32 | };
33 | }
34 |
35 | describe('renderToPipeableStream', () => {
36 | it('should render non-suspended JSX in one go', async () => {
37 | const sink = createSink();
38 | const { pipe } = renderToPipeableStream(bar
, {
39 | onAllReady: () => {
40 | pipe(sink.stream);
41 | }
42 | });
43 | const result = await sink.promise;
44 |
45 | expect(result).to.deep.equal(['bar
']);
46 | });
47 |
48 | it('should render fallback + attach loaded subtree on suspend', async () => {
49 | const { Suspender, suspended } = createSuspender();
50 |
51 | const sink = createSink();
52 | const { pipe } = renderToPipeableStream(
53 |
54 |
55 |
56 |
57 |
,
58 | {
59 | onShellReady: () => {
60 | pipe(sink.stream);
61 | }
62 | }
63 | );
64 | suspended.resolve();
65 |
66 | const result = await sink.promise;
67 |
68 | expect(result).to.deep.equal([
69 | 'loading...
',
70 | '',
71 | createInitScript(),
72 | createSubtree('5', '
it works
'),
73 | '
'
74 | ]);
75 | });
76 |
77 | it('should not error if the stream has already been closed', async () => {
78 | let error;
79 | const sink = createSink();
80 | const { pipe, abort } = renderToPipeableStream(bar
, {
81 | onAllReady: () => {
82 | pipe(sink.stream);
83 | },
84 | onError: (e) => (error = e)
85 | });
86 | await sink.promise;
87 | abort();
88 |
89 | expect(error).to.be.undefined;
90 | });
91 | });
92 |
--------------------------------------------------------------------------------
/test/compat/stream.test.jsx:
--------------------------------------------------------------------------------
1 | /*global globalThis*/
2 | import { h } from 'preact';
3 | import { expect, beforeAll, describe, it } from 'vitest';
4 | import { Suspense } from 'preact/compat';
5 | import { createSubtree, createInitScript } from '../../src/lib/client';
6 | import { renderToReadableStream } from '../../src/stream';
7 | import { Deferred } from '../../src/lib/util';
8 | import { createSuspender } from '../utils';
9 |
10 | /**
11 | * @param {ReadableStream} input
12 | */
13 | function createSink(input) {
14 | const decoder = new TextDecoder('utf-8');
15 | const queuingStrategy = new CountQueuingStrategy({ highWaterMark: 1 });
16 |
17 | const def = new Deferred();
18 | const result = [];
19 |
20 | const stream = new WritableStream(
21 | {
22 | // Implement the sink
23 | write(chunk) {
24 | result.push(decoder.decode(chunk));
25 | },
26 | close() {
27 | def.resolve(result);
28 | },
29 | abort(err) {
30 | def.reject(err);
31 | }
32 | },
33 | queuingStrategy
34 | );
35 |
36 | input.pipeTo(stream);
37 |
38 | return {
39 | promise: def.promise,
40 | stream
41 | };
42 | }
43 |
44 | describe('renderToReadableStream', () => {
45 | beforeAll(async () => {
46 | // attempt to use native web streams in Node 18, otherwise fall back to a polyfill:
47 | let streams;
48 | try {
49 | streams = await import('node:stream/web');
50 | } catch {
51 | streams = await import('web-streams-polyfill/ponyfill');
52 | }
53 | const { ReadableStream, WritableStream, CountQueuingStrategy } = streams;
54 |
55 | globalThis.ReadableStream = ReadableStream;
56 | globalThis.WritableStream = WritableStream;
57 | globalThis.CountQueuingStrategy = CountQueuingStrategy;
58 | });
59 |
60 | it('should render non-suspended JSX in one go', async () => {
61 | const stream = await renderToReadableStream(bar
);
62 | const sink = createSink(stream);
63 | const result = await sink.promise;
64 |
65 | expect(result).to.deep.equal(['bar
']);
66 | });
67 |
68 | it('should render fallback + attach loaded subtree on suspend', async () => {
69 | const { Suspender, suspended } = createSuspender();
70 |
71 | const stream = renderToReadableStream(
72 |
73 |
74 |
75 |
76 |
,
77 | { onWrite: (s) => result.push(s) }
78 | );
79 | const sink = createSink(stream);
80 | suspended.resolve();
81 |
82 | const result = await sink.promise;
83 |
84 | expect(result).toEqual([
85 | 'loading...
',
86 | '',
87 | createInitScript(),
88 | createSubtree('5', '
it works
'),
89 | '
'
90 | ]);
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/src/lib/chunked.js:
--------------------------------------------------------------------------------
1 | import { renderToString } from '../index.js';
2 | import { CHILD_DID_SUSPEND, COMPONENT, PARENT } from './constants.js';
3 | import { Deferred } from './util.js';
4 | import { createInitScript, createSubtree } from './client.js';
5 |
6 | /**
7 | * @param {VNode} vnode
8 | * @param {RenderToChunksOptions} options
9 | * @returns {Promise}
10 | */
11 | export async function renderToChunks(vnode, { context, onWrite, abortSignal }) {
12 | context = context || {};
13 |
14 | /** @type {RendererState} */
15 | const renderer = {
16 | start: Date.now(),
17 | abortSignal,
18 | onWrite,
19 | onError: handleError,
20 | suspended: []
21 | };
22 |
23 | // Synchronously render the shell
24 | // @ts-ignore - using third internal RendererState argument
25 | const shell = renderToString(vnode, context, renderer);
26 | onWrite(shell);
27 |
28 | // Wait for any suspended sub-trees if there are any
29 | const len = renderer.suspended.length;
30 | if (len > 0) {
31 | onWrite('');
32 | onWrite(createInitScript(len));
33 | // We should keep checking all promises
34 | await forkPromises(renderer);
35 | onWrite('
');
36 | }
37 | }
38 |
39 | async function forkPromises(renderer) {
40 | if (renderer.suspended.length > 0) {
41 | const suspensions = [...renderer.suspended];
42 | await Promise.all(renderer.suspended.map((s) => s.promise));
43 | renderer.suspended = renderer.suspended.filter(
44 | (s) => !suspensions.includes(s)
45 | );
46 | await forkPromises(renderer);
47 | }
48 | }
49 |
50 | /** @type {RendererErrorHandler} */
51 | function handleError(error, vnode, renderChild) {
52 | if (!error || !error.then) return;
53 |
54 | // walk up to the Suspense boundary
55 | while ((vnode = vnode[PARENT])) {
56 | let component = vnode[COMPONENT];
57 | if (component && component[CHILD_DID_SUSPEND]) {
58 | break;
59 | }
60 | }
61 |
62 | if (!vnode) return;
63 |
64 | const id = vnode.__v;
65 | const found = this.suspended.find((x) => x.id === id);
66 | const race = new Deferred();
67 |
68 | const abortSignal = this.abortSignal;
69 | if (abortSignal) {
70 | // @ts-ignore 2554 - implicit undefined arg
71 | if (abortSignal.aborted) race.resolve();
72 | else abortSignal.addEventListener('abort', race.resolve);
73 | }
74 |
75 | const promise = error.then(
76 | () => {
77 | if (abortSignal && abortSignal.aborted) return;
78 | const child = renderChild(vnode.props.children, vnode);
79 | if (child) this.onWrite(createSubtree(id, child));
80 | },
81 | // TODO: Abort and send hydration code snippet to client
82 | // to attempt to recover during hydration
83 | this.onError
84 | );
85 |
86 | this.suspended.push({
87 | id,
88 | vnode,
89 | promise: Promise.race([promise, race.promise])
90 | });
91 |
92 | const fallback = renderChild(vnode.props.fallback);
93 |
94 | return found
95 | ? ''
96 | : `${fallback}`;
97 | }
98 |
--------------------------------------------------------------------------------
/src/jsx.js:
--------------------------------------------------------------------------------
1 | import './lib/polyfills.js';
2 | import renderToString from './pretty.js';
3 | import { indent, encodeEntities } from './lib/util.js';
4 | import prettyFormat from 'pretty-format';
5 |
6 | // we have to patch in Array support, Possible issue in npm.im/pretty-format
7 | let preactPlugin = {
8 | test(object) {
9 | return (
10 | object &&
11 | typeof object === 'object' &&
12 | 'type' in object &&
13 | 'props' in object &&
14 | 'key' in object
15 | );
16 | },
17 | print(val) {
18 | return renderToString(val, preactPlugin.context, preactPlugin.opts, true);
19 | }
20 | };
21 |
22 | let prettyFormatOpts = {
23 | plugins: [preactPlugin]
24 | };
25 |
26 | function attributeHook(name, value, context, opts, isComponent) {
27 | let type = typeof value;
28 |
29 | // Use render-to-string's built-in handling for these properties
30 | if (name === 'dangerouslySetInnerHTML') return false;
31 |
32 | // always skip null & undefined values, skip false DOM attributes, skip functions if told to
33 | if (value == null || (type === 'function' && !opts.functions)) return '';
34 |
35 | if (
36 | opts.skipFalseAttributes &&
37 | !isComponent &&
38 | (value === false ||
39 | ((name === 'class' || name === 'style') && value === ''))
40 | )
41 | return '';
42 |
43 | let indentChar = typeof opts.pretty === 'string' ? opts.pretty : '\t';
44 | if (type !== 'string') {
45 | if (type === 'function' && !opts.functionNames) {
46 | value = 'Function';
47 | } else {
48 | preactPlugin.context = context;
49 | preactPlugin.opts = opts;
50 | value = prettyFormat(value, prettyFormatOpts);
51 | if (~value.indexOf('\n')) {
52 | value = `${indent('\n' + value, indentChar)}\n`;
53 | }
54 | }
55 | return indent(`\n${name}={${value}}`, indentChar);
56 | }
57 | return `\n${indentChar}${name}="${encodeEntities(value)}"`;
58 | }
59 |
60 | let defaultOpts = {
61 | attributeHook,
62 | jsx: true,
63 | xml: false,
64 | functions: true,
65 | functionNames: true,
66 | skipFalseAttributes: true,
67 | pretty: ' '
68 | };
69 |
70 | /**
71 | * Render Preact JSX + Components to a pretty-printed HTML-like string.
72 | * @param {VNode} vnode JSX Element / VNode to render
73 | * @param {Object} [context={}] Initial root context object
74 | * @param {Object} [options={}] Rendering options
75 | * @param {Boolean} [options.jsx=true] Generate JSX/XML output instead of HTML
76 | * @param {Boolean} [options.xml=false] Use self-closing tags for elements without children
77 | * @param {Boolean} [options.shallow=false] Serialize nested Components (` `) instead of rendering
78 | * @param {Boolean} [options.pretty=false] Add whitespace for readability
79 | * @param {RegExp|undefined} [options.voidElements] RegeEx to define which element types are self-closing
80 | * @returns {String} a pretty-printed HTML-like string
81 | */
82 | export default function renderToStringPretty(vnode, context, options) {
83 | const opts = Object.assign({}, defaultOpts, options || {});
84 | if (!opts.jsx) opts.attributeHook = null;
85 | return renderToString(vnode, context, opts);
86 | }
87 | export { renderToStringPretty as render };
88 |
89 | const SHALLOW = { shallow: true };
90 |
91 | /** Only render elements, leaving Components inline as ` `.
92 | * This method is just a convenience alias for `render(vnode, context, { shallow:true })`
93 | * @name shallow
94 | * @function
95 | * @param {VNode} vnode JSX VNode to render.
96 | * @param {Object} [context={}] Optionally pass an initial context object through the render path.
97 | * @param {Parameters[2]} [options] Optionally pass an initial context object through the render path.
98 | */
99 | export function shallowRender(vnode, context, options) {
100 | const opts = Object.assign({}, SHALLOW, options || {});
101 | return renderToStringPretty(vnode, context, opts);
102 | }
103 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # preact-render-to-string
2 |
3 | [](https://www.npmjs.com/package/preact-render-to-string)
4 | [](https://github.com/preactjs/preact-render-to-string/actions/workflows/ci.yml)
5 |
6 | Render JSX and [Preact](https://github.com/preactjs/preact) components to an HTML string.
7 |
8 | Works in Node & the browser, making it useful for universal/isomorphic rendering.
9 |
10 | \>\> **[Cute Fox-Related Demo](http://codepen.io/developit/pen/dYZqjE?editors=001)** _(@ CodePen)_ <<
11 |
12 | ---
13 |
14 | ### Render JSX/VDOM to HTML
15 |
16 | ```js
17 | import { render } from 'preact-render-to-string';
18 | import { h } from 'preact';
19 | /** @jsx h */
20 |
21 | let vdom = content
;
22 |
23 | let html = render(vdom);
24 | console.log(html);
25 | // content
26 | ```
27 |
28 | ### Render Preact Components to HTML
29 |
30 | ```js
31 | import { render } from 'preact-render-to-string';
32 | import { h, Component } from 'preact';
33 | /** @jsx h */
34 |
35 | // Classical components work
36 | class Fox extends Component {
37 | render({ name }) {
38 | return {name} ;
39 | }
40 | }
41 |
42 | // ... and so do pure functional components:
43 | const Box = ({ type, children }) => (
44 | {children}
45 | );
46 |
47 | let html = render(
48 |
49 |
50 |
51 | );
52 |
53 | console.log(html);
54 | // Finn
55 | ```
56 |
57 | ---
58 |
59 | ### Render JSX / Preact / Whatever via Express!
60 |
61 | ```js
62 | import express from 'express';
63 | import { h } from 'preact';
64 | import { render } from 'preact-render-to-string';
65 | /** @jsx h */
66 |
67 | // silly example component:
68 | const Fox = ({ name }) => (
69 |
70 |
{name}
71 |
This page is all about {name}.
72 |
73 | );
74 |
75 | // basic HTTP server via express:
76 | const app = express();
77 | app.listen(8080);
78 |
79 | // on each request, render and return a component:
80 | app.get('/:fox', (req, res) => {
81 | let html = render( );
82 | // send it back wrapped up as an HTML5 document:
83 | res.send(`${html}`);
84 | });
85 | ```
86 |
87 | ### Error Boundaries
88 |
89 | Rendering errors can be caught by Preact via `getDerivedStateFromErrors` or `componentDidCatch`. To enable that feature in `preact-render-to-string` set `errorBoundaries = true`
90 |
91 | ```js
92 | import { options } from 'preact';
93 |
94 | // Enable error boundaries in `preact-render-to-string`
95 | options.errorBoundaries = true;
96 | ```
97 |
98 | ---
99 |
100 | ### `Suspense` & `lazy` components with [`preact/compat`](https://www.npmjs.com/package/preact)
101 |
102 | ```bash
103 | npm install preact preact-render-to-string
104 | ```
105 |
106 | ```jsx
107 | export default () => {
108 | return Home page ;
109 | };
110 | ```
111 |
112 | ```jsx
113 | import { Suspense, lazy } from 'preact/compat';
114 |
115 | // Creation of the lazy component
116 | const HomePage = lazy(() => import('./pages/home'));
117 |
118 | const Main = () => {
119 | return (
120 | Loading
}>
121 |
122 |
123 | );
124 | };
125 | ```
126 |
127 | ```jsx
128 | import { renderToStringAsync } from 'preact-render-to-string';
129 | import { Main } from './main';
130 |
131 | const main = async () => {
132 | // Rendering of lazy components
133 | const html = await renderToStringAsync( );
134 |
135 | console.log(html);
136 | // Home page
137 | };
138 |
139 | // Execution & error handling
140 | main().catch((error) => {
141 | console.error(error);
142 | });
143 | ```
144 |
145 | ---
146 |
147 | ### License
148 |
149 | [MIT](http://choosealicense.com/licenses/mit/)
150 |
--------------------------------------------------------------------------------
/test/jsx.test.jsx:
--------------------------------------------------------------------------------
1 | import render from '../src/jsx.js';
2 | import { h } from 'preact';
3 | import { expect, describe, it } from 'vitest';
4 | import { dedent } from './utils.jsx';
5 |
6 | describe('jsx', () => {
7 | let renderJsx = (jsx, opts) => render(jsx, null, opts).replace(/ {2}/g, '\t');
8 |
9 | it('should render as JSX', () => {
10 | let rendered = renderJsx(
11 |
12 | foo
13 | bar
14 | hello
15 |
16 | );
17 |
18 | expect(rendered).to.equal(dedent`
19 |
20 | foo
21 | bar
22 | hello
23 |
24 | `);
25 | });
26 |
27 | it('should not render empty class or style DOM attributes', () => {
28 | expect(renderJsx( )).to.equal(' ');
29 | expect(renderJsx( )).to.equal(' ');
30 | expect(renderJsx( )).to.equal(' ');
31 | expect(renderJsx( )).to.equal(' ');
32 | expect(renderJsx( )).to.equal(' ');
33 | expect(renderJsx( )).to.equal(' ');
34 | });
35 |
36 | it('should render JSX attributes inline if short enough', () => {
37 | expect(renderJsx(bar )).to.equal(dedent`
38 | bar
39 | `);
40 |
41 | expect(renderJsx(bar )).to.equal(dedent`
42 | bar
43 | `);
44 |
45 | expect(renderJsx(bar )).to.equal(dedent`
46 | bar
47 | `);
48 |
49 | function F() {}
50 | expect(
51 | renderJsx(bar , {
52 | shallow: true,
53 | renderRootComponent: false
54 | })
55 | ).to.equal(dedent`
56 | bar
57 | `);
58 | });
59 |
60 | it('should render JSX attributes as multiline if complex', () => {
61 | expect(renderJsx(bar )).to.equal(dedent`
62 |
72 | bar
73 |
74 | `);
75 | });
76 |
77 | it('should skip null and undefined attributes', () => {
78 | expect(renderJsx(bar )).to.equal(`bar `);
79 |
80 | expect(renderJsx(bar )).to.equal(`bar `);
81 | });
82 |
83 | it('should render attributes containing VNodes', () => {
84 | expect(renderJsx( }>bar)).to.equal(dedent`
85 | }>bar
86 | `);
87 |
88 | expect(renderJsx( , ]}>bar)).to.equal(dedent`
89 | ,
93 |
94 | ]
95 | }
96 | >
97 | bar
98 |
99 | `);
100 | });
101 |
102 | it('should render empty resolved children identically to no children', () => {
103 | const Empty = () => null;
104 | const False = () => false;
105 | expect(
106 | renderJsx(
107 |
108 |
109 |
{null}
110 |
111 |
112 |
113 |
{false}
114 |
115 |
116 |
117 |
118 | )
119 | ).to.equal(dedent`
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 | `);
128 | });
129 |
130 | it('should skip null siblings', () => {
131 | expect(
132 | renderJsx(
133 |
134 |
135 | {null}
136 |
137 | )
138 | ).to.deep.equal(dedent`
139 |
140 |
141 |
142 | `);
143 | });
144 |
145 | it('should skip functions if functions=false', () => {
146 | expect(
147 | renderJsx( {}} />, { functions: false })
148 | ).to.equal('
');
149 | });
150 |
151 | it('should skip function names if functionNames=false', () => {
152 | expect(
153 | renderJsx(
{}} />, { functionNames: false })
154 | ).to.equal('
');
155 |
156 | expect(
157 | renderJsx(
, { functionNames: false })
158 | ).to.equal('
');
159 | });
160 |
161 | it('should render self-closing elements', () => {
162 | expect(renderJsx(
)).to.deep.equal(dedent`
163 |
164 | `);
165 | });
166 |
167 | it('should prevent JSON injection', () => {
168 | expect(renderJsx(
{{ hello: 'world' }}
)).to.equal('
');
169 | });
170 |
171 | it('should not render function children', () => {
172 | expect(renderJsx(
{() => {}}
)).to.equal('
');
173 | });
174 |
175 | it('should render enumerated attributes', () => {
176 | expect(renderJsx(
)).to.equal(
177 | '
'
178 | );
179 | expect(renderJsx(
)).to.equal(
180 | '
'
181 | );
182 | });
183 | });
184 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "preact-render-to-string",
3 | "amdName": "preactRenderToString",
4 | "version": "6.6.4",
5 | "description": "Render JSX to an HTML string, with support for Preact components.",
6 | "main": "dist/index.js",
7 | "umd:main": "dist/index.umd.js",
8 | "module": "dist/index.module.js",
9 | "jsnext:main": "dist/index.module.js",
10 | "types": "dist/index.d.ts",
11 | "exports": {
12 | ".": {
13 | "types": "./dist/index.d.ts",
14 | "browser": "./dist/index.module.js",
15 | "umd": "./dist/index.umd.js",
16 | "import": "./dist/index.mjs",
17 | "require": "./dist/index.js"
18 | },
19 | "./jsx": {
20 | "types": "./dist/jsx.d.ts",
21 | "browser": "./dist/jsx/index.module.js",
22 | "umd": "./dist/jsx/index.umd.js",
23 | "import": "./dist/jsx/index.mjs",
24 | "require": "./dist/jsx/index.js"
25 | },
26 | "./stream": {
27 | "types": "./dist/stream.d.ts",
28 | "browser": "./dist/stream/index.module.js",
29 | "import": "./dist/stream/index.mjs",
30 | "require": "./dist/stream/index.js"
31 | },
32 | "./stream-node": {
33 | "types": "./dist/stream-node.d.ts",
34 | "import": "./dist/stream/node/index.mjs",
35 | "require": "./dist/stream/node/index.js"
36 | },
37 | "./package.json": "./package.json"
38 | },
39 | "scripts": {
40 | "prebench": "npm run build",
41 | "bench": "BABEL_ENV=test node -r @babel/register benchmarks index.js",
42 | "bench:v8": "BABEL_ENV=test microbundle benchmarks/index.js -f modern --alias benchmarkjs-pretty=benchmarks/lib/benchmark-lite.js --external none --target node --no-compress --no-sourcemap --raw -o benchmarks/.v8.mjs && v8 --module benchmarks/.v8.modern.js",
43 | "build": "npm run -s transpile && npm run -s transpile:jsx && npm run -s transpile:stream && npm run -s transpile:stream-node && npm run -s copy-typescript-definition",
44 | "postbuild": "node ./config/node-13-exports.js && node ./config/node-commonjs.js && node ./config/node-verify-exports.js && check-export-map",
45 | "transpile": "microbundle src/index.js -f es,cjs,umd",
46 | "transpile:stream": "microbundle src/stream.js -o dist/stream/index.js -f es,cjs,umd",
47 | "transpile:stream-node": "microbundle src/stream-node.js -o dist/stream/node/index.js -f es,cjs,umd --target node",
48 | "transpile:jsx": "microbundle src/jsx.js -o dist/jsx/index.js -f es,cjs,umd && microbundle dist/jsx/index.js -o dist/jsx/index.js -f cjs",
49 | "copy-typescript-definition": "copyfiles -f src/*.d.ts dist",
50 | "test": "oxlint && tsc && npm run test:vitest:run && npm run bench",
51 | "test:vitest": "vitest",
52 | "test:vitest:run": "vitest run",
53 | "format": "prettier src/**/*.{d.ts,js} test/**/*.{js,jsx} --write",
54 | "prepublishOnly": "npm run build",
55 | "release": "npm run build && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish"
56 | },
57 | "keywords": [
58 | "preact",
59 | "render",
60 | "universal",
61 | "isomorphic"
62 | ],
63 | "files": [
64 | "src",
65 | "dist",
66 | "jsx.js",
67 | "jsx.d.ts",
68 | "typings.json"
69 | ],
70 | "babel": {
71 | "env": {
72 | "test": {
73 | "presets": [
74 | [
75 | "@babel/preset-env",
76 | {
77 | "targets": {
78 | "node": true
79 | }
80 | }
81 | ]
82 | ],
83 | "plugins": [
84 | [
85 | "@babel/plugin-transform-react-jsx",
86 | {
87 | "pragma": "h"
88 | }
89 | ]
90 | ]
91 | }
92 | }
93 | },
94 | "minify": {
95 | "compress": {
96 | "reduce_funcs": false
97 | }
98 | },
99 | "author": "The Preact Authors (https://github.com/preactjs/preact/contributors)",
100 | "license": "MIT",
101 | "repository": {
102 | "type": "git",
103 | "url": "https://github.com/preactjs/preact-render-to-string"
104 | },
105 | "bugs": "https://github.com/preactjs/preact-render-to-string/issues",
106 | "homepage": "https://github.com/preactjs/preact-render-to-string",
107 | "peerDependencies": {
108 | "preact": ">=10 || >= 11.0.0-0"
109 | },
110 | "devDependencies": {
111 | "@babel/plugin-transform-react-jsx": "^7.12.12",
112 | "@babel/preset-env": "^7.12.11",
113 | "@babel/register": "^7.12.10",
114 | "@changesets/changelog-github": "^0.4.1",
115 | "@changesets/cli": "^2.18.0",
116 | "@preact/signals-core": "^1.11.0",
117 | "baseline-rts": "npm:preact-render-to-string@latest",
118 | "benchmarkjs-pretty": "^2.0.1",
119 | "check-export-map": "^1.3.1",
120 | "copyfiles": "^2.4.1",
121 | "husky": "^4.3.6",
122 | "lint-staged": "^10.5.3",
123 | "microbundle": "^0.15.1",
124 | "oxlint": "^1.3.0",
125 | "preact": "^10.24.0",
126 | "prettier": "^2.2.1",
127 | "pretty-format": "^3.8.0",
128 | "rollup": "^4.44.1",
129 | "typescript": "^5.0.0",
130 | "vitest": "^3.2.4",
131 | "web-streams-polyfill": "^3.2.1"
132 | },
133 | "prettier": {
134 | "singleQuote": true,
135 | "trailingComma": "none",
136 | "useTabs": true,
137 | "tabWidth": 2
138 | },
139 | "lint-staged": {
140 | "**/*.{js,jsx,ts,tsx,yml}": [
141 | "prettier --write"
142 | ]
143 | },
144 | "husky": {
145 | "hooks": {
146 | "pre-commit": "lint-staged"
147 | }
148 | },
149 | "publishConfig": {
150 | "provenance": true
151 | }
152 | }
--------------------------------------------------------------------------------
/src/lib/util.js:
--------------------------------------------------------------------------------
1 | import { DIRTY, BITS } from './constants';
2 |
3 | export const VOID_ELEMENTS = /^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;
4 | // oxlint-disable-next-line no-control-regex
5 | export const UNSAFE_NAME = /[\s\n\\/='"\0<>]/;
6 | export const NAMESPACE_REPLACE_REGEX = /^(xlink|xmlns|xml)([A-Z])/;
7 | export const HTML_LOWER_CASE = /^(?:accessK|auto[A-Z]|cell|ch|col|cont|cross|dateT|encT|form[A-Z]|frame|hrefL|inputM|maxL|minL|noV|playsI|popoverT|readO|rowS|src[A-Z]|tabI|useM|item[A-Z])/;
8 | export const SVG_CAMEL_CASE = /^ac|^ali|arabic|basel|cap|clipPath$|clipRule$|color|dominant|enable|fill|flood|font|glyph[^R]|horiz|image|letter|lighting|marker[^WUH]|overline|panose|pointe|paint|rendering|shape|stop|strikethrough|stroke|text[^L]|transform|underline|unicode|units|^v[^i]|^w|^xH/;
9 |
10 | // Boolean DOM properties that translate to enumerated ('true'/'false') attributes
11 | export const HTML_ENUMERATED = new Set(['draggable', 'spellcheck']);
12 |
13 | export const COMPONENT_DIRTY_BIT = 1 << 3;
14 | export function setDirty(component) {
15 | if (component[BITS] !== undefined) {
16 | component[BITS] |= COMPONENT_DIRTY_BIT;
17 | } else {
18 | component[DIRTY] = true;
19 | }
20 | }
21 |
22 | export function unsetDirty(component) {
23 | if (component.__g !== undefined) {
24 | component.__g &= ~COMPONENT_DIRTY_BIT;
25 | } else {
26 | component[DIRTY] = false;
27 | }
28 | }
29 |
30 | export function isDirty(component) {
31 | if (component.__g !== undefined) {
32 | return (component.__g & COMPONENT_DIRTY_BIT) !== 0;
33 | }
34 | return component[DIRTY] === true;
35 | }
36 |
37 | // DOM properties that should NOT have "px" added when numeric
38 | const ENCODED_ENTITIES = /["&<]/;
39 |
40 | /** @param {string} str */
41 | export function encodeEntities(str) {
42 | // Skip all work for strings with no entities needing encoding:
43 | if (str.length === 0 || ENCODED_ENTITIES.test(str) === false) return str;
44 |
45 | let last = 0,
46 | i = 0,
47 | out = '',
48 | ch = '';
49 |
50 | // Seek forward in str until the next entity char:
51 | for (; i < str.length; i++) {
52 | switch (str.charCodeAt(i)) {
53 | case 34:
54 | ch = '"';
55 | break;
56 | case 38:
57 | ch = '&';
58 | break;
59 | case 60:
60 | ch = '<';
61 | break;
62 | default:
63 | continue;
64 | }
65 | // Append skipped/buffered characters and the encoded entity:
66 | if (i !== last) out = out + str.slice(last, i);
67 | out = out + ch;
68 | // Start the next seek/buffer after the entity's offset:
69 | last = i + 1;
70 | }
71 | if (i !== last) out = out + str.slice(last, i);
72 | return out;
73 | }
74 |
75 | export let indent = (s, char) =>
76 | String(s).replace(/(\n+)/g, '$1' + (char || '\t'));
77 |
78 | export let isLargeString = (s, length, ignoreLines) =>
79 | String(s).length > (length || 40) ||
80 | (!ignoreLines && String(s).indexOf('\n') !== -1) ||
81 | String(s).indexOf('<') !== -1;
82 |
83 | const JS_TO_CSS = {};
84 |
85 | const IS_NON_DIMENSIONAL = new Set([
86 | 'animation-iteration-count',
87 | 'border-image-outset',
88 | 'border-image-slice',
89 | 'border-image-width',
90 | 'box-flex',
91 | 'box-flex-group',
92 | 'box-ordinal-group',
93 | 'column-count',
94 | 'fill-opacity',
95 | 'flex',
96 | 'flex-grow',
97 | 'flex-negative',
98 | 'flex-order',
99 | 'flex-positive',
100 | 'flex-shrink',
101 | 'flood-opacity',
102 | 'font-weight',
103 | 'grid-column',
104 | 'grid-row',
105 | 'line-clamp',
106 | 'line-height',
107 | 'opacity',
108 | 'order',
109 | 'orphans',
110 | 'stop-opacity',
111 | 'stroke-dasharray',
112 | 'stroke-dashoffset',
113 | 'stroke-miterlimit',
114 | 'stroke-opacity',
115 | 'stroke-width',
116 | 'tab-size',
117 | 'widows',
118 | 'z-index',
119 | 'zoom'
120 | ]);
121 |
122 | const CSS_REGEX = /[A-Z]/g;
123 | // Convert an Object style to a CSSText string
124 | export function styleObjToCss(s) {
125 | let str = '';
126 | for (let prop in s) {
127 | let val = s[prop];
128 | if (val != null && val !== '') {
129 | const name =
130 | prop[0] == '-'
131 | ? prop
132 | : JS_TO_CSS[prop] ||
133 | (JS_TO_CSS[prop] = prop.replace(CSS_REGEX, '-$&').toLowerCase());
134 |
135 | let suffix = ';';
136 | if (
137 | typeof val === 'number' &&
138 | // Exclude custom-attributes
139 | !name.startsWith('--') &&
140 | !IS_NON_DIMENSIONAL.has(name)
141 | ) {
142 | suffix = 'px;';
143 | }
144 | str = str + name + ':' + val + suffix;
145 | }
146 | }
147 | return str || undefined;
148 | }
149 |
150 | /**
151 | * Get flattened children from the children prop
152 | * @param {Array} accumulator
153 | * @param {any} children A `props.children` opaque object.
154 | * @returns {Array} accumulator
155 | * @private
156 | */
157 | export function getChildren(accumulator, children) {
158 | if (Array.isArray(children)) {
159 | children.reduce(getChildren, accumulator);
160 | } else if (children != null && children !== false) {
161 | accumulator.push(children);
162 | }
163 | return accumulator;
164 | }
165 |
166 | function markAsDirty() {
167 | this.__d = true;
168 | }
169 |
170 | export function createComponent(vnode, context) {
171 | return {
172 | __v: vnode,
173 | context,
174 | props: vnode.props,
175 | // silently drop state updates
176 | setState: markAsDirty,
177 | forceUpdate: markAsDirty,
178 | __d: true,
179 | // hooks
180 | // oxlint-disable-next-line no-new-array
181 | __h: new Array(0)
182 | };
183 | }
184 |
185 | // Necessary for createContext api. Setting this property will pass
186 | // the context value as `this.context` just for this component.
187 | export function getContext(nodeName, context) {
188 | let cxType = nodeName.contextType;
189 | let provider = cxType && context[cxType.__c];
190 | return cxType != null
191 | ? provider
192 | ? provider.props.value
193 | : cxType.__
194 | : context;
195 | }
196 |
197 | /**
198 | * @template T
199 | */
200 | export class Deferred {
201 | constructor() {
202 | /** @type {Promise
} */
203 | this.promise = new Promise((resolve, reject) => {
204 | this.resolve = resolve;
205 | this.reject = reject;
206 | });
207 | }
208 | }
209 |
--------------------------------------------------------------------------------
/test/compat/render-chunked.test.jsx:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { expect, describe, it } from 'vitest';
3 | import { Suspense } from 'preact/compat';
4 | import { useId } from 'preact/hooks';
5 | import { renderToChunks } from '../../src/lib/chunked';
6 | import { createSubtree, createInitScript } from '../../src/lib/client';
7 | import { createSuspender } from '../utils';
8 | import { VNODE, PARENT } from '../../src/lib/constants';
9 |
10 | describe('renderToChunks', () => {
11 | it('should render non-suspended JSX in one go', async () => {
12 | const result = [];
13 | await renderToChunks(bar
, {
14 | onWrite: (s) => result.push(s)
15 | });
16 |
17 | expect(result).toEqual(['bar
']);
18 | });
19 |
20 | it('should render fallback + attach loaded subtree on suspend', async () => {
21 | const { Suspender, suspended } = createSuspender();
22 |
23 | const result = [];
24 | const promise = renderToChunks(
25 |
26 |
27 |
28 |
29 |
,
30 | { onWrite: (s) => result.push(s) }
31 | );
32 | suspended.resolve();
33 |
34 | await promise;
35 |
36 | expect(result).to.deep.equal([
37 | 'loading...
',
38 | '',
39 | createInitScript(),
40 | createSubtree('5', '
it works
'),
41 | '
'
42 | ]);
43 | });
44 |
45 | it('should abort pending suspensions with AbortSignal', async () => {
46 | const { Suspender, suspended } = createSuspender();
47 |
48 | const controller = new AbortController();
49 | const result = [];
50 | const promise = renderToChunks(
51 |
52 |
53 |
54 |
55 |
,
56 | { onWrite: (s) => result.push(s), abortSignal: controller.signal }
57 | );
58 |
59 | controller.abort();
60 | await promise;
61 |
62 | suspended.resolve();
63 |
64 | expect(result).to.deep.equal([
65 | 'loading...
',
66 | '',
67 | createInitScript(1),
68 | '
'
69 | ]);
70 | });
71 |
72 | it('should encounter no circular references when rendering a suspense boundary subtree', async () => {
73 | const { Suspender, suspended } = createSuspender();
74 |
75 | const visited = new Set();
76 | let circular = false;
77 |
78 | function CircularReferenceCheck() {
79 | let root = this[VNODE];
80 | while (root !== null && root[PARENT] !== null) {
81 | if (visited.has(root)) {
82 | // Can't throw an error here, _catchError handler will also loop infinitely
83 | circular = true;
84 | break;
85 | }
86 | visited.add(root);
87 | root = root[PARENT];
88 | }
89 | return it works
;
90 | }
91 |
92 | const result = [];
93 | const promise = renderToChunks(
94 |
95 |
96 |
97 |
98 |
99 |
100 |
,
101 | { onWrite: (s) => result.push(s) }
102 | );
103 |
104 | suspended.resolve();
105 | await promise;
106 |
107 | if (circular) {
108 | throw new Error('CircularReference');
109 | }
110 |
111 | expect(result).to.deep.equal([
112 | 'loading...
',
113 | '',
114 | createInitScript(1),
115 | createSubtree('16', '
it works
'),
116 | '
'
117 | ]);
118 | });
119 |
120 | it('should support using useId hooks inside a suspense boundary', async () => {
121 | const { Suspender, suspended } = createSuspender();
122 |
123 | function ComponentWithId() {
124 | const id = useId();
125 | return id: {id}
;
126 | }
127 |
128 | const result = [];
129 | const promise = renderToChunks(
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
,
138 | { onWrite: (s) => result.push(s) }
139 | );
140 |
141 | suspended.resolve();
142 | await promise;
143 |
144 | expect(result).to.deep.equal([
145 | '',
146 | '',
147 | createInitScript(1),
148 | createSubtree('24', '
id: P0-1
'),
149 | '
'
150 | ]);
151 | });
152 |
153 | it('should support using multiple useId hooks inside multiple suspense boundaries', async () => {
154 | const { Suspender, suspended } = createSuspender();
155 | const { Suspender: Suspender2, suspended: suspended2 } = createSuspender();
156 |
157 | function ComponentWithId() {
158 | const id = useId();
159 | return id: {id}
;
160 | }
161 |
162 | const result = [];
163 | const promise = renderToChunks(
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
,
177 | { onWrite: (s) => result.push(s) }
178 | );
179 |
180 | suspended.resolve();
181 | suspended2.resolve();
182 | await promise;
183 |
184 | expect(result).toEqual([
185 | 'id: P0-0
loading...loading...
',
186 | '',
187 | createInitScript(1),
188 | createSubtree('33', '
id: P0-1
'),
189 | createSubtree('36', '
id: P0-2
'),
190 | '
'
191 | ]);
192 | });
193 |
194 | it('should support a component that suspends multiple times', async () => {
195 | const { Suspender, suspended } = createSuspender();
196 | const { Suspender: Suspender2, suspended: suspended2 } = createSuspender();
197 |
198 | function MultiSuspender() {
199 | return (
200 |
201 |
202 |
203 |
204 | );
205 | }
206 |
207 | const result = [];
208 | const promise = renderToChunks(
209 |
210 |
211 |
,
212 | { onWrite: (s) => result.push(s) }
213 | );
214 |
215 | suspended.resolve();
216 | suspended2.resolve();
217 | await promise;
218 |
219 | expect(result).to.deep.equal([
220 | 'loading part 1...
',
221 | '',
222 | createInitScript(1),
223 | createSubtree('49', '
it works
it works
'),
224 | '
'
225 | ]);
226 | });
227 | });
228 |
--------------------------------------------------------------------------------
/test/pretty.test.jsx:
--------------------------------------------------------------------------------
1 | import basicRender from '../src/index.js';
2 | import { render } from '../src/jsx.js';
3 | import { h, Fragment } from 'preact';
4 | import { expect, describe, it } from 'vitest';
5 | import { dedent, svgAttributes, htmlAttributes } from './utils.jsx';
6 |
7 | describe('pretty', () => {
8 | let prettyRender = (jsx, opts) => render(jsx, {}, { pretty: true, ...opts });
9 |
10 | it('should render no whitespace by default', () => {
11 | let rendered = basicRender(
12 |
13 | foo
14 | bar
15 | hello
16 |
17 | );
18 |
19 | expect(rendered).to.equal(
20 | ``
21 | );
22 | });
23 |
24 | it('should render whitespace when pretty=true', () => {
25 | let rendered = prettyRender(
26 |
27 | foo
28 | bar
29 | hello
30 |
31 | );
32 |
33 | expect(rendered).to.equal(
34 | `\n\tfoo \n\tbar\n\thello
\n `
35 | );
36 | });
37 |
38 | it('should not indent for short children', () => {
39 | let fourty = '';
40 | for (let i = 40; i--; ) fourty += 'x';
41 |
42 | expect(
43 | prettyRender({fourty} ),
44 | '<=40 characters'
45 | ).to.equal(`${fourty} `);
46 |
47 | expect(
48 | prettyRender({fourty + 'a'} ),
49 | '>40 characters'
50 | ).to.equal(`\n\t${fourty + 'a'}\n `);
51 | });
52 |
53 | it('should handle self-closing tags', () => {
54 | expect(
55 | prettyRender(
56 |
57 | hi
58 |
59 |
60 |
hi
61 |
62 | )
63 | ).to.equal(
64 | `\n\thi\n\t
\n\t
\n\t
hi \n
`
65 | );
66 | });
67 |
68 | it('should support empty tags', () => {
69 | expect(
70 | prettyRender(
71 |
72 |
73 |
74 | )
75 | ).to.equal(`\n\t \n
`);
76 | });
77 |
78 | it('should not increase indentation with Fragments', () => {
79 | expect(
80 | prettyRender(
81 |
82 |
83 |
84 |
85 |
86 | )
87 | ).to.equal(`\n\t \n
`);
88 | });
89 |
90 | it('should not increase indentation with nested Fragments', () => {
91 | expect(
92 | prettyRender(
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | )
101 | ).to.equal(`\n\t \n
`);
102 | });
103 |
104 | it('should not increase indentation with sibling Fragments', () => {
105 | expect(
106 | prettyRender(
107 |
108 |
109 | A
110 |
111 |
112 | B
113 |
114 |
115 | )
116 | ).to.equal(``);
117 | });
118 |
119 | it('should join adjacent text nodes', () => {
120 | // prettier-ignore
121 | expect(prettyRender(
122 | hello{' '}
123 | )).to.equal(`\n\thello \n\t \n
`);
124 |
125 | // prettier-ignore
126 | expect(prettyRender(
127 | hello{' '} {'a'}{'b'}
128 | )).to.equal(`\n\thello \n\t \n\tab\n
`);
129 | });
130 |
131 | it('should join adjacent text nodeswith Fragments', () => {
132 | // prettier-ignore
133 | expect(prettyRender(
134 | foo bar{' '}
135 | )).to.equal(`\n\tfoobar \n\t \n
`);
136 | });
137 |
138 | it('should collapse whitespace', () => {
139 | expect(
140 | prettyRender(
141 |
142 | ab
143 |
144 | )
145 | ).to.equal(`\n\ta\n\tb \n
`);
146 |
147 | expect(
148 | prettyRender(
149 |
150 | a b
151 |
152 | )
153 | ).to.equal(`\n\ta \n\tb \n
`);
154 |
155 | expect(
156 | prettyRender(
157 |
158 | a{''}
159 | b
160 |
161 | )
162 | ).to.equal(dedent`
163 |
164 | a
165 | b
166 |
167 | `);
168 |
169 | expect(
170 | prettyRender(
171 |
172 | a b
173 |
174 | )
175 | ).to.equal(`\n\ta \n\tb \n
`);
176 |
177 | expect(prettyRender( b )).to.equal(dedent`
178 | b
179 | `);
180 |
181 | expect(
182 | prettyRender(
183 |
184 | a{' '}
185 |
186 | )
187 | ).to.equal(`\n\t \n\t a \n
`);
188 | });
189 |
190 | it('should prevent JSON injection', () => {
191 | expect(prettyRender({{ hello: 'world' }}
)).to.equal(
192 | '
'
193 | );
194 | });
195 |
196 | it('should not render function children', () => {
197 | expect(prettyRender({() => {}}
)).to.equal('
');
198 | });
199 |
200 | it('should render SVG elements', () => {
201 | let rendered = prettyRender(
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 | );
212 |
213 | expect(rendered).to.equal(
214 | `\n\t \n\t\n\t\t
\n\t \n\t\n\t\t \n\t \n `
215 | );
216 | });
217 |
218 | it('should not add whitespace to pre tag children', () => {
219 | let rendered = prettyRender(
220 |
221 | hello
222 | ,
223 | { jsx: false }
224 | );
225 |
226 | expect(rendered).to.equal(`hello `);
227 | });
228 |
229 | it('should maintain whitespace in textarea tag', () => {
230 | let rendered = prettyRender(, {
231 | jsx: false
232 | });
233 |
234 | expect(rendered).to.equal(``);
235 | });
236 |
237 | describe('Attribute casing', () => {
238 | it('should have correct SVG casing', () => {
239 | for (let name in svgAttributes) {
240 | let value = svgAttributes[name];
241 |
242 | let rendered = prettyRender(
243 |
244 |
245 |
246 | );
247 | expect(rendered).to.equal(
248 | `\n\t \n `
249 | );
250 | }
251 | });
252 |
253 | it('should have correct HTML casing', () => {
254 | for (let name in htmlAttributes) {
255 | let value = htmlAttributes[name];
256 |
257 | if (name === 'checked') {
258 | let rendered = prettyRender( , {
259 | jsx: false
260 | });
261 | expect(rendered).to.equal(` `);
262 | continue;
263 | } else {
264 | let rendered = prettyRender(
);
265 | expect(rendered).to.equal(`
`);
266 | }
267 | }
268 | });
269 | });
270 | });
271 |
--------------------------------------------------------------------------------
/test/compat/async.test.jsx:
--------------------------------------------------------------------------------
1 | import { renderToStringAsync } from '../../src/index.js';
2 | import { h, Fragment } from 'preact';
3 | import { Suspense, useId, lazy, createContext } from 'preact/compat';
4 | import { expect, describe, it } from 'vitest';
5 | import { createSuspender } from '../utils.jsx';
6 | const wait = (ms) => new Promise((r) => setTimeout(r, ms));
7 |
8 | describe('Async renderToString', () => {
9 | it('should render JSX after a suspense boundary', async () => {
10 | const { Suspender, suspended } = createSuspender();
11 |
12 | const promise = renderToStringAsync(
13 | loading... }>
14 |
15 | bar
16 |
17 |
18 | );
19 |
20 | const expected = `
bar
`;
21 |
22 | suspended.resolve();
23 |
24 | const rendered = await promise;
25 |
26 | expect(rendered).to.equal(expected);
27 | });
28 |
29 | it('should correctly denote null returns of suspending components', async () => {
30 | const { Suspender, suspended } = createSuspender();
31 |
32 | const Analytics = () => null;
33 |
34 | const promise = renderToStringAsync(
35 |
loading... }>
36 |
37 |
38 |
39 | bar
40 |
41 | );
42 |
43 | const expected = `bar
`;
44 |
45 | suspended.resolve();
46 |
47 | const rendered = await promise;
48 |
49 | expect(rendered).to.equal(expected);
50 | });
51 |
52 | it('should render JSX with nested suspended components', async () => {
53 | const {
54 | Suspender: SuspenderOne,
55 | suspended: suspendedOne
56 | } = createSuspender();
57 | const {
58 | Suspender: SuspenderTwo,
59 | suspended: suspendedTwo
60 | } = createSuspender();
61 |
62 | const promise = renderToStringAsync(
63 |
64 |
65 |
66 | one
67 |
68 | two
69 |
70 | three
71 |
72 |
73 |
74 | );
75 |
76 | const expected = ``;
77 |
78 | suspendedOne.resolve();
79 | suspendedTwo.resolve();
80 |
81 | const rendered = await promise;
82 |
83 | expect(rendered).to.equal(expected);
84 | });
85 |
86 | it('should render JSX with nested suspense boundaries', async () => {
87 | const {
88 | Suspender: SuspenderOne,
89 | suspended: suspendedOne
90 | } = createSuspender();
91 | const {
92 | Suspender: SuspenderTwo,
93 | suspended: suspendedTwo
94 | } = createSuspender();
95 |
96 | const promise = renderToStringAsync(
97 |
98 |
99 |
100 | one
101 |
102 |
103 | two
104 |
105 |
106 | three
107 |
108 |
109 |
110 | );
111 |
112 | const expected = ``;
113 |
114 | suspendedOne.resolve();
115 | suspendedTwo.resolve();
116 |
117 | const rendered = await promise;
118 |
119 | expect(rendered).to.equal(expected);
120 | });
121 |
122 | it('should render JSX with nested suspense boundaries containing multiple suspending components', async () => {
123 | const {
124 | Suspender: SuspenderOne,
125 | suspended: suspendedOne
126 | } = createSuspender();
127 | const {
128 | Suspender: SuspenderTwo,
129 | suspended: suspendedTwo
130 | } = createSuspender();
131 | const {
132 | Suspender: SuspenderThree,
133 | suspended: suspendedThree
134 | } = createSuspender('three');
135 |
136 | const promise = renderToStringAsync(
137 |
138 |
139 |
140 | one
141 |
142 |
143 | two
144 |
145 |
146 | three
147 |
148 |
149 | four
150 |
151 |
152 |
153 | );
154 |
155 | const expected = ``;
156 |
157 | suspendedOne.resolve();
158 | suspendedTwo.resolve();
159 | await wait(0);
160 | suspendedThree.resolve();
161 |
162 | const rendered = await promise;
163 |
164 | expect(rendered).to.equal(expected);
165 | });
166 |
167 | it('should render JSX with deeply nested suspense boundaries', async () => {
168 | const {
169 | Suspender: SuspenderOne,
170 | suspended: suspendedOne
171 | } = createSuspender();
172 | const {
173 | Suspender: SuspenderTwo,
174 | suspended: suspendedTwo
175 | } = createSuspender();
176 | const {
177 | Suspender: SuspenderThree,
178 | suspended: suspendedThree
179 | } = createSuspender();
180 |
181 | const promise = renderToStringAsync(
182 |
183 |
184 |
185 | one
186 |
187 |
188 | two
189 |
190 |
191 | three
192 |
193 |
194 |
195 |
196 | four
197 |
198 |
199 |
200 | );
201 |
202 | const expected = ``;
203 |
204 | suspendedOne.resolve();
205 | suspendedTwo.resolve();
206 | await wait(0);
207 | suspendedThree.resolve();
208 |
209 | const rendered = await promise;
210 |
211 | expect(rendered).to.equal(expected);
212 | });
213 |
214 | it('should render JSX with multiple suspended direct children within a single suspense boundary', async () => {
215 | const {
216 | Suspender: SuspenderOne,
217 | suspended: suspendedOne
218 | } = createSuspender();
219 | const {
220 | Suspender: SuspenderTwo,
221 | suspended: suspendedTwo
222 | } = createSuspender();
223 | const {
224 | Suspender: SuspenderThree,
225 | suspended: suspendedThree
226 | } = createSuspender();
227 |
228 | const promise = renderToStringAsync(
229 |
230 |
231 |
232 | one
233 |
234 |
235 |
236 | two
237 |
238 |
239 |
240 | three
241 |
242 |
243 |
244 | );
245 |
246 | const expected = ``;
247 |
248 | suspendedOne.resolve();
249 | suspendedTwo.resolve();
250 | suspendedThree.resolve();
251 |
252 | const rendered = await promise;
253 |
254 | expect(rendered).to.equal(expected);
255 | });
256 |
257 | it('should render JSX with multiple suspended direct children within a single suspense boundary that resolve one-after-another', async () => {
258 | const {
259 | Suspender: SuspenderOne,
260 | suspended: suspendedOne
261 | } = createSuspender();
262 | const {
263 | Suspender: SuspenderTwo,
264 | suspended: suspendedTwo
265 | } = createSuspender();
266 | const {
267 | Suspender: SuspenderThree,
268 | suspended: suspendedThree
269 | } = createSuspender();
270 |
271 | const promise = renderToStringAsync(
272 |
273 |
274 |
275 | one
276 |
277 |
278 | two
279 |
280 |
281 | three
282 |
283 |
284 |
285 | );
286 |
287 | const expected = ``;
288 |
289 | suspendedOne.promise.then(() => {
290 | void suspendedTwo.resolve();
291 | });
292 | suspendedTwo.promise.then(() => {
293 | void suspendedThree.resolve();
294 | });
295 |
296 | suspendedOne.resolve();
297 |
298 | const rendered = await promise;
299 |
300 | expect(rendered).to.equal(expected);
301 | });
302 |
303 | it('should rethrow error thrown after suspending', async () => {
304 | const { suspended, getResolved } = createSuspender();
305 |
306 | function Suspender() {
307 | if (!getResolved()) {
308 | throw suspended.promise;
309 | }
310 |
311 | throw new Error('fail');
312 | }
313 |
314 | const promise = renderToStringAsync(
315 | loading...}>
316 |
317 |
318 | );
319 |
320 | let msg = '';
321 | try {
322 | suspended.resolve();
323 | await promise;
324 | } catch (err) {
325 | msg = err.message;
326 | }
327 |
328 | expect(msg).to.equal('fail');
329 | });
330 |
331 | it('should support hooks', async () => {
332 | const { suspended, getResolved } = createSuspender();
333 |
334 | function Suspender() {
335 | const id = useId();
336 |
337 | if (!getResolved()) {
338 | throw suspended.promise;
339 | }
340 |
341 | return {typeof id === 'string' ? 'ok' : 'fail'}
;
342 | }
343 |
344 | const promise = renderToStringAsync(
345 | loading...}>
346 |
347 |
348 | );
349 |
350 | suspended.resolve();
351 | const rendered = await promise;
352 | expect(rendered).to.equal('ok
');
353 | });
354 |
355 | it('should work with an in-render suspension', async () => {
356 | const Context = createContext();
357 |
358 | let c = 0;
359 |
360 | const Fetcher = ({ children }) => {
361 | c++;
362 | if (c === 1) {
363 | throw Promise.resolve();
364 | }
365 | return {children} ;
366 | };
367 |
368 | const LazyComponent = lazy(
369 | async () =>
370 | function ImportedComponent() {
371 | return 2
;
372 | }
373 | );
374 |
375 | const LoadableComponent = () => (
376 |
377 |
378 |
379 | );
380 |
381 | const rendered = await renderToStringAsync(
382 |
383 |
384 |
385 |
386 |
387 | );
388 |
389 | // Before we get to the actual DOM this suspends twice
390 | expect(rendered).to.equal(
391 | `2
`
392 | );
393 | });
394 |
395 | describe('dangerouslySetInnerHTML', () => {
396 | it('should support dangerouslySetInnerHTML', async () => {
397 | // some invalid HTML to make sure we're being flakey:
398 | let html = 'asdf some text ';
399 | let rendered = await renderToStringAsync(
400 |
401 | );
402 | expect(rendered).to.equal(`${html}
`);
403 | });
404 |
405 | it('should accept undefined dangerouslySetInnerHTML', async () => {
406 | const Test = () => (
407 |
408 | hi
409 |
410 |
411 | );
412 |
413 | const rendered = await renderToStringAsync( );
414 | expect(rendered).to.equal('hi
');
415 | });
416 |
417 | it('should accept null __html', async () => {
418 | const Test = () => (
419 |
420 | hi
421 |
422 |
423 | );
424 | const rendered = await renderToStringAsync( );
425 | expect(rendered).to.equal('hi
');
426 | });
427 |
428 | it('should override children', async () => {
429 | let rendered = await renderToStringAsync(
430 |
431 | bar
432 |
433 | );
434 | expect(rendered).to.equal('foo
');
435 | });
436 | });
437 | });
438 |
--------------------------------------------------------------------------------
/test/utils.jsx:
--------------------------------------------------------------------------------
1 | import { h } from 'preact';
2 | import { Deferred } from '../src/lib/util';
3 |
4 | /**
5 | * tag to remove leading whitespace from tagged template
6 | * literal.
7 | * @param {TemplateStringsArray}
8 | * @returns {string}
9 | */
10 | export function dedent([str]) {
11 | return str
12 | .split('\n' + str.match(/^\n*(\s+)/)[1])
13 | .join('\n')
14 | .replace(/(^\n+|\n+\s*$)/g, '');
15 | }
16 |
17 | const defaultChildren = it works
;
18 | export function createSuspender() {
19 | const deferred = new Deferred();
20 | let resolved;
21 |
22 | deferred.promise.then(() => (resolved = true));
23 | function Suspender({ children = defaultChildren }) {
24 | if (!resolved) {
25 | throw deferred.promise;
26 | }
27 |
28 | return children;
29 | }
30 |
31 | return {
32 | getResolved() {
33 | return resolved;
34 | },
35 | suspended: deferred,
36 | Suspender
37 | };
38 | }
39 | export const svgAttributes = {
40 | accentHeight: 'accent-height',
41 | accumulate: 'accumulate',
42 | additive: 'additive',
43 | alignmentBaseline: 'alignment-baseline',
44 | allowReorder: 'allowReorder', // unsure
45 | alphabetic: 'alphabetic',
46 | amplitude: 'amplitude',
47 | arabicForm: 'arabic-form',
48 | ascent: 'ascent',
49 | attributeName: 'attributeName',
50 | attributeType: 'attributeType',
51 | autoReverse: 'autoReverse', // unsure
52 | azimuth: 'azimuth',
53 | baseFrequency: 'baseFrequency',
54 | baselineShift: 'baseline-shift',
55 | baseProfile: 'baseProfile',
56 | bbox: 'bbox',
57 | begin: 'begin',
58 | bias: 'bias',
59 | by: 'by',
60 | calcMode: 'calcMode',
61 | capHeight: 'cap-height',
62 | class: 'class',
63 | clip: 'clip',
64 | clipPathUnits: 'clipPathUnits',
65 | clipPath: 'clip-path',
66 | clipRule: 'clip-rule',
67 | color: 'color',
68 | colorInterpolation: 'color-interpolation',
69 | colorInterpolationFilters: 'color-interpolation-filters',
70 | colorProfile: 'color-profile',
71 | colorRendering: 'color-rendering',
72 | contentScriptType: 'contentScriptType',
73 | contentStyleType: 'contentStyleType',
74 | crossorigin: 'crossorigin',
75 | cursor: 'cursor',
76 | cx: 'cx',
77 | cy: 'cy',
78 | d: 'd',
79 | decelerate: 'decelerate',
80 | descent: 'descent',
81 | diffuseConstant: 'diffuseConstant',
82 | direction: 'direction',
83 | display: 'display',
84 | divisor: 'divisor',
85 | dominantBaseline: 'dominant-baseline',
86 | dur: 'dur',
87 | dx: 'dx',
88 | dy: 'dy',
89 | edgeMode: 'edgeMode',
90 | elevation: 'elevation',
91 | enableBackground: 'enable-background',
92 | end: 'end',
93 | exponent: 'exponent',
94 | fill: 'fill',
95 | fillOpacity: 'fill-opacity',
96 | fillRule: 'fill-rule',
97 | filter: 'filter',
98 | filterRes: 'filterRes',
99 | filterUnits: 'filterUnits',
100 | floodColor: 'flood-color',
101 | floodOpacity: 'flood-opacity',
102 | fontFamily: 'font-family',
103 | fontSize: 'font-size',
104 | fontSizeAdjust: 'font-size-adjust',
105 | fontStretch: 'font-stretch',
106 | fontStyle: 'font-style',
107 | fontVariant: 'font-variant',
108 | fontWeight: 'font-weight',
109 | format: 'format',
110 | from: 'from',
111 | fx: 'fx',
112 | fy: 'fy',
113 | g1: 'g1',
114 | g2: 'g2',
115 | glyphName: 'glyph-name',
116 | glyphOrientationHorizontal: 'glyph-orientation-horizontal',
117 | glyphOrientationVertical: 'glyph-orientation-vertical',
118 | glyphRef: 'glyphRef',
119 | gradientTransform: 'gradientTransform',
120 | gradientUnits: 'gradientUnits',
121 | hanging: 'hanging',
122 | horizAdvX: 'horiz-adv-x',
123 | horizOriginX: 'horiz-origin-x',
124 | ideographic: 'ideographic',
125 | imageRendering: 'image-rendering',
126 | in: 'in',
127 | in2: 'in2',
128 | intercept: 'intercept',
129 | k: 'k',
130 | k1: 'k1',
131 | k2: 'k2',
132 | k3: 'k3',
133 | k4: 'k4',
134 | kernelMatrix: 'kernelMatrix',
135 | kernelUnitLength: 'kernelUnitLength',
136 | kerning: 'kerning',
137 | keyPoints: 'keyPoints',
138 | keySplines: 'keySplines',
139 | keyTimes: 'keyTimes',
140 | lengthAdjust: 'lengthAdjust',
141 | letterSpacing: 'letter-spacing',
142 | lightingColor: 'lighting-color',
143 | limitingConeAngle: 'limitingConeAngle',
144 | local: 'local',
145 | markerEnd: 'marker-end',
146 | markerMid: 'marker-mid',
147 | markerStart: 'marker-start',
148 | markerHeight: 'markerHeight',
149 | markerUnits: 'markerUnits',
150 | markerWidth: 'markerWidth',
151 | mask: 'mask',
152 | maskContentUnits: 'maskContentUnits',
153 | maskUnits: 'maskUnits',
154 | mathematical: 'mathematical',
155 | numOctaves: 'numOctaves',
156 | offset: 'offset',
157 | opacity: 'opacity',
158 | operator: 'operator',
159 | order: 'order',
160 | orient: 'orient',
161 | orientation: 'orientation',
162 | origin: 'origin',
163 | overflow: 'overflow',
164 | overlinePosition: 'overline-position',
165 | overlineThickness: 'overline-thickness',
166 | panose1: 'panose-1',
167 | paintOrder: 'paint-order',
168 | pathLength: 'pathLength',
169 | patternContentUnits: 'patternContentUnits',
170 | patternTransform: 'patternTransform',
171 | patternUnits: 'patternUnits',
172 | pointerEvents: 'pointer-events',
173 | points: 'points',
174 | pointsAtX: 'pointsAtX',
175 | pointsAtY: 'pointsAtY',
176 | pointsAtZ: 'pointsAtZ',
177 | preserveAlpha: 'preserveAlpha',
178 | preserveAspectRatio: 'preserveAspectRatio',
179 | primitiveUnits: 'primitiveUnits',
180 | r: 'r',
181 | radius: 'radius',
182 | refX: 'refX',
183 | refY: 'refY',
184 | rel: 'rel',
185 | renderingIntent: 'rendering-intent',
186 | repeatCount: 'repeatCount',
187 | repeatDur: 'repeatDur',
188 | requiredExtensions: 'requiredExtensions',
189 | requiredFeatures: 'requiredFeatures',
190 | restart: 'restart',
191 | result: 'result',
192 | rotate: 'rotate',
193 | rx: 'rx',
194 | ry: 'ry',
195 | scale: 'scale',
196 | seed: 'seed',
197 | shapeRendering: 'shape-rendering',
198 | slope: 'slope',
199 | spacing: 'spacing',
200 | specularConstant: 'specularConstant',
201 | specularExponent: 'specularExponent',
202 | speed: 'speed',
203 | spreadMethod: 'spreadMethod',
204 | startOffset: 'startOffset',
205 | stdDeviation: 'stdDeviation',
206 | stemh: 'stemh',
207 | stemv: 'stemv',
208 | stitchTiles: 'stitchTiles',
209 | stopColor: 'stop-color',
210 | stopOpacity: 'stop-opacity',
211 | strikethroughPosition: 'strikethrough-position',
212 | strikethroughThickness: 'strikethrough-thickness',
213 | string: 'string',
214 | stroke: 'stroke',
215 | strokeDasharray: 'stroke-dasharray',
216 | strokeDashoffset: 'stroke-dashoffset',
217 | strokeLinecap: 'stroke-linecap',
218 | strokeLinejoin: 'stroke-linejoin',
219 | strokeMiterlimit: 'stroke-miterlimit',
220 | strokeOpacity: 'stroke-opacity',
221 | strokeWidth: 'stroke-width',
222 | surfaceScale: 'surfaceScale',
223 | systemLanguage: 'systemLanguage',
224 | tableValues: 'tableValues',
225 | targetX: 'targetX',
226 | targetY: 'targetY',
227 | textAnchor: 'text-anchor',
228 | textDecoration: 'text-decoration',
229 | textRendering: 'text-rendering',
230 | textLength: 'textLength',
231 | to: 'to',
232 | transform: 'transform',
233 | transformOrigin: 'transform-origin',
234 | u1: 'u1',
235 | u2: 'u2',
236 | underlinePosition: 'underline-position',
237 | underlineThickness: 'underline-thickness',
238 | unicode: 'unicode',
239 | unicodeBidi: 'unicode-bidi',
240 | unicodeRange: 'unicode-range',
241 | unitsPerEm: 'units-per-em',
242 | vAlphabetic: 'v-alphabetic',
243 | vHanging: 'v-hanging',
244 | vIdeographic: 'v-ideographic',
245 | vMathematical: 'v-mathematical',
246 | values: 'values',
247 | vectorEffect: 'vector-effect',
248 | version: 'version',
249 | vertAdvY: 'vert-adv-y',
250 | vertOriginX: 'vert-origin-x',
251 | vertOriginY: 'vert-origin-y',
252 | viewBox: 'viewBox',
253 | viewTarget: 'viewTarget',
254 | visibility: 'visibility',
255 | widths: 'widths',
256 | wordSpacing: 'word-spacing',
257 | writingMode: 'writing-mode',
258 | x: 'x',
259 | xHeight: 'x-height',
260 | x1: 'x1',
261 | x2: 'x2',
262 | xChannelSelector: 'xChannelSelector',
263 | xlinkActuate: 'xlink:actuate',
264 | xlinkArcrole: 'xlink:arcrole',
265 | xlinkHref: 'xlink:href',
266 | xlinkRole: 'xlink:role',
267 | xlinkShow: 'xlink:show',
268 | xlinkTitle: 'xlink:title',
269 | xlinkType: 'xlink:type',
270 | xmlBase: 'xml:base',
271 | xmlLang: 'xml:lang',
272 | xmlSpace: 'xml:space',
273 | y: 'y',
274 | y1: 'y1',
275 | y2: 'y2',
276 | yChannelSelector: 'yChannelSelector',
277 | z: 'z',
278 | zoomAndPan: 'zoomAndPan'
279 | };
280 |
281 | export const htmlAttributes = {
282 | accept: 'accept',
283 | acceptCharset: 'accept-charset',
284 | accessKey: 'accesskey',
285 | action: 'action',
286 | allow: 'allow',
287 | // allowFullScreen: '', // unsure?
288 | // allowTransparency: '', // unsure?
289 | alt: 'alt',
290 | as: 'as',
291 | async: 'async',
292 | autocomplete: 'autocomplete',
293 | autoComplete: 'autocomplete',
294 | autocorrect: 'autocorrect',
295 | autoCorrect: 'autocorrect',
296 | autofocus: 'autofocus',
297 | autoFocus: 'autofocus',
298 | autoPlay: 'autoplay',
299 | capture: 'capture',
300 | cellPadding: 'cellpadding',
301 | cellSpacing: 'cellspacing',
302 | charSet: 'charset',
303 | challenge: 'challenge',
304 | checked: 'checked',
305 | cite: 'cite',
306 | class: 'class',
307 | className: 'class',
308 | cols: 'cols',
309 | colSpan: 'colspan',
310 | content: 'content',
311 | contentEditable: 'contenteditable',
312 | contextMenu: 'contextmenu',
313 | controls: 'controls',
314 | coords: 'coords',
315 | crossOrigin: 'crossorigin',
316 | data: 'data',
317 | dateTime: 'datetime',
318 | default: 'default',
319 | defaultChecked: 'checked',
320 | defaultValue: 'value',
321 | defer: 'defer',
322 | dir: 'dir',
323 | disabled: 'disabled',
324 | download: 'download',
325 | decoding: 'decoding',
326 | draggable: 'draggable',
327 | encType: 'enctype',
328 | enterkeyhint: 'enterkeyhint',
329 | for: 'for',
330 | form: 'form',
331 | formAction: 'formaction',
332 | formEncType: 'formenctype',
333 | formMethod: 'formmethod',
334 | formNoValidate: 'formnovalidate',
335 | formTarget: 'formtarget',
336 | frameBorder: 'frameborder',
337 | headers: 'headers',
338 | height: 'height',
339 | hidden: 'hidden',
340 | high: 'high',
341 | href: 'href',
342 | hrefLang: 'hreflang',
343 | htmlFor: 'for',
344 | httpEquiv: 'http-equiv',
345 | icon: 'icon',
346 | id: 'id',
347 | indeterminate: 'indeterminate',
348 | inputMode: 'inputmode',
349 | integrity: 'integrity',
350 | is: 'is',
351 | kind: 'kind',
352 | label: 'label',
353 | lang: 'lang',
354 | list: 'list',
355 | loading: 'loading',
356 | loop: 'loop',
357 | low: 'low',
358 | manifest: 'manifest',
359 | max: 'max',
360 | maxLength: 'maxlength',
361 | media: 'media',
362 | method: 'method',
363 | min: 'min',
364 | minLength: 'minlength',
365 | multiple: 'multiple',
366 | muted: 'muted',
367 | name: 'name',
368 | nomodule: 'nomodule',
369 | nonce: 'nonce',
370 | noValidate: 'novalidate',
371 | open: 'open',
372 | optimum: 'optimum',
373 | part: 'part',
374 | pattern: 'pattern',
375 | ping: 'ping',
376 | placeholder: 'placeholder',
377 | playsInline: 'playsinline',
378 | popoverTarget: 'popovertarget',
379 | popoverTargetAction: 'popovertargetaction',
380 | poster: 'poster',
381 | preload: 'preload',
382 | readonly: 'readonly',
383 | readOnly: 'readonly',
384 | referrerpolicy: 'referrerpolicy',
385 | rel: 'rel',
386 | required: 'required',
387 | reversed: 'reversed',
388 | role: 'role',
389 | rows: 'rows',
390 | rowSpan: 'rowspan',
391 | sandbox: 'sandbox',
392 | scope: 'scope',
393 | scoped: 'scoped',
394 | scrolling: 'scrolling',
395 | seamless: 'seamless',
396 | selected: 'selected',
397 | shape: 'shape',
398 | size: 'size',
399 | sizes: 'sizes',
400 | slot: 'slot',
401 | span: 'span',
402 | spellcheck: 'spellcheck',
403 | src: 'src',
404 | srcset: 'srcset',
405 | srcDoc: 'srcdoc',
406 | srcLang: 'srclang',
407 | srcSet: 'srcset',
408 | start: 'start',
409 | step: 'step',
410 | style: 'style',
411 | summary: 'summary',
412 | tabIndex: 'tabindex',
413 | target: 'target',
414 | title: 'title',
415 | type: 'type',
416 | useMap: 'usemap',
417 | value: 'value',
418 | volume: 'volume',
419 | width: 'width',
420 | wmode: 'wmode',
421 | wrap: 'wrap',
422 |
423 | // Non-standard Attributes
424 | autocapitalize: 'autocapitalize',
425 | autoCapitalize: 'autocapitalize',
426 | results: 'results',
427 | translate: 'translate',
428 |
429 | // RDFa Attributes
430 | about: 'about',
431 | datatype: 'datatype',
432 | inlist: 'inlist',
433 | prefix: 'prefix',
434 | property: 'property',
435 | resource: 'resource',
436 | typeof: 'typeof',
437 | vocab: 'vocab',
438 |
439 | // Microdata Attributes
440 | itemProp: 'itemprop',
441 | itemScope: 'itemscope',
442 | itemType: 'itemtype',
443 | itemID: 'itemid',
444 | itemRef: 'itemref'
445 | };
446 |
--------------------------------------------------------------------------------
/benchmarks/isomorphic-ui/_colors.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | hex: '#EFDECD',
4 | name: 'Almond',
5 | rgb: '(239, 222, 205)'
6 | },
7 | {
8 | hex: '#CD9575',
9 | name: 'Antique Brass',
10 | rgb: '(205, 149, 117)'
11 | },
12 | {
13 | hex: '#FDD9B5',
14 | name: 'Apricot',
15 | rgb: '(253, 217, 181)'
16 | },
17 | {
18 | hex: '#78DBE2',
19 | name: 'Aquamarine',
20 | rgb: '(120, 219, 226)'
21 | },
22 | {
23 | hex: '#87A96B',
24 | name: 'Asparagus',
25 | rgb: '(135, 169, 107)'
26 | },
27 | {
28 | hex: '#FFA474',
29 | name: 'Atomic Tangerine',
30 | rgb: '(255, 164, 116)'
31 | },
32 | {
33 | hex: '#FAE7B5',
34 | name: 'Banana Mania',
35 | rgb: '(250, 231, 181)'
36 | },
37 | {
38 | hex: '#9F8170',
39 | name: 'Beaver',
40 | rgb: '(159, 129, 112)'
41 | },
42 | {
43 | hex: '#FD7C6E',
44 | name: 'Bittersweet',
45 | rgb: '(253, 124, 110)'
46 | },
47 | {
48 | hex: '#000000',
49 | name: 'Black',
50 | rgb: '(0,0,0)'
51 | },
52 | {
53 | hex: '#ACE5EE',
54 | name: 'Blizzard Blue',
55 | rgb: '(172, 229, 238)'
56 | },
57 | {
58 | hex: '#1F75FE',
59 | name: 'Blue',
60 | rgb: '(31, 117, 254)'
61 | },
62 | {
63 | hex: '#A2A2D0',
64 | name: 'Blue Bell',
65 | rgb: '(162, 162, 208)'
66 | },
67 | {
68 | hex: '#6699CC',
69 | name: 'Blue Gray',
70 | rgb: '(102, 153, 204)'
71 | },
72 | {
73 | hex: '#0D98BA',
74 | name: 'Blue Green',
75 | rgb: '(13, 152, 186)'
76 | },
77 | {
78 | hex: '#7366BD',
79 | name: 'Blue Violet',
80 | rgb: '(115, 102, 189)'
81 | },
82 | {
83 | hex: '#DE5D83',
84 | name: 'Blush',
85 | rgb: '(222, 93, 131)'
86 | },
87 | {
88 | hex: '#CB4154',
89 | name: 'Brick Red',
90 | rgb: '(203, 65, 84)'
91 | },
92 | {
93 | hex: '#B4674D',
94 | name: 'Brown',
95 | rgb: '(180, 103, 77)'
96 | },
97 | {
98 | hex: '#FF7F49',
99 | name: 'Burnt Orange',
100 | rgb: '(255, 127, 73)'
101 | },
102 | {
103 | hex: '#EA7E5D',
104 | name: 'Burnt Sienna',
105 | rgb: '(234, 126, 93)'
106 | },
107 | {
108 | hex: '#B0B7C6',
109 | name: 'Cadet Blue',
110 | rgb: '(176, 183, 198)'
111 | },
112 | {
113 | hex: '#FFFF99',
114 | name: 'Canary',
115 | rgb: '(255, 255, 153)'
116 | },
117 | {
118 | hex: '#1CD3A2',
119 | name: 'Caribbean Green',
120 | rgb: '(28, 211, 162)'
121 | },
122 | {
123 | hex: '#FFAACC',
124 | name: 'Carnation Pink',
125 | rgb: '(255, 170, 204)'
126 | },
127 | {
128 | hex: '#DD4492',
129 | name: 'Cerise',
130 | rgb: '(221, 68, 146)'
131 | },
132 | {
133 | hex: '#1DACD6',
134 | name: 'Cerulean',
135 | rgb: '(29, 172, 214)'
136 | },
137 | {
138 | hex: '#BC5D58',
139 | name: 'Chestnut',
140 | rgb: '(188, 93, 88)'
141 | },
142 | {
143 | hex: '#DD9475',
144 | name: 'Copper',
145 | rgb: '(221, 148, 117)'
146 | },
147 | {
148 | hex: '#9ACEEB',
149 | name: 'Cornflower',
150 | rgb: '(154, 206, 235)'
151 | },
152 | {
153 | hex: '#FFBCD9',
154 | name: 'Cotton Candy',
155 | rgb: '(255, 188, 217)'
156 | },
157 | {
158 | hex: '#FDDB6D',
159 | name: 'Dandelion',
160 | rgb: '(253, 219, 109)'
161 | },
162 | {
163 | hex: '#2B6CC4',
164 | name: 'Denim',
165 | rgb: '(43, 108, 196)'
166 | },
167 | {
168 | hex: '#EFCDB8',
169 | name: 'Desert Sand',
170 | rgb: '(239, 205, 184)'
171 | },
172 | {
173 | hex: '#6E5160',
174 | name: 'Eggplant',
175 | rgb: '(110, 81, 96)'
176 | },
177 | {
178 | hex: '#CEFF1D',
179 | name: 'Electric Lime',
180 | rgb: '(206, 255, 29)'
181 | },
182 | {
183 | hex: '#71BC78',
184 | name: 'Fern',
185 | rgb: '(113, 188, 120)'
186 | },
187 | {
188 | hex: '#6DAE81',
189 | name: 'Forest Green',
190 | rgb: '(109, 174, 129)'
191 | },
192 | {
193 | hex: '#C364C5',
194 | name: 'Fuchsia',
195 | rgb: '(195, 100, 197)'
196 | },
197 | {
198 | hex: '#CC6666',
199 | name: 'Fuzzy Wuzzy',
200 | rgb: '(204, 102, 102)'
201 | },
202 | {
203 | hex: '#E7C697',
204 | name: 'Gold',
205 | rgb: '(231, 198, 151)'
206 | },
207 | {
208 | hex: '#FCD975',
209 | name: 'Goldenrod',
210 | rgb: '(252, 217, 117)'
211 | },
212 | {
213 | hex: '#A8E4A0',
214 | name: 'Granny Smith Apple',
215 | rgb: '(168, 228, 160)'
216 | },
217 | {
218 | hex: '#95918C',
219 | name: 'Gray',
220 | rgb: '(149, 145, 140)'
221 | },
222 | {
223 | hex: '#1CAC78',
224 | name: 'Green',
225 | rgb: '(28, 172, 120)'
226 | },
227 | {
228 | hex: '#1164B4',
229 | name: 'Green Blue',
230 | rgb: '(17, 100, 180)'
231 | },
232 | {
233 | hex: '#F0E891',
234 | name: 'Green Yellow',
235 | rgb: '(240, 232, 145)'
236 | },
237 | {
238 | hex: '#FF1DCE',
239 | name: 'Hot Magenta',
240 | rgb: '(255, 29, 206)'
241 | },
242 | {
243 | hex: '#B2EC5D',
244 | name: 'Inchworm',
245 | rgb: '(178, 236, 93)'
246 | },
247 | {
248 | hex: '#5D76CB',
249 | name: 'Indigo',
250 | rgb: '(93, 118, 203)'
251 | },
252 | {
253 | hex: '#CA3767',
254 | name: 'Jazzberry Jam',
255 | rgb: '(202, 55, 103)'
256 | },
257 | {
258 | hex: '#3BB08F',
259 | name: 'Jungle Green',
260 | rgb: '(59, 176, 143)'
261 | },
262 | {
263 | hex: '#FEFE22',
264 | name: 'Laser Lemon',
265 | rgb: '(254, 254, 34)'
266 | },
267 | {
268 | hex: '#FCB4D5',
269 | name: 'Lavender',
270 | rgb: '(252, 180, 213)'
271 | },
272 | {
273 | hex: '#FFF44F',
274 | name: 'Lemon Yellow',
275 | rgb: '(255, 244, 79)'
276 | },
277 | {
278 | hex: '#FFBD88',
279 | name: 'Macaroni and Cheese',
280 | rgb: '(255, 189, 136)'
281 | },
282 | {
283 | hex: '#F664AF',
284 | name: 'Magenta',
285 | rgb: '(246, 100, 175)'
286 | },
287 | {
288 | hex: '#AAF0D1',
289 | name: 'Magic Mint',
290 | rgb: '(170, 240, 209)'
291 | },
292 | {
293 | hex: '#CD4A4C',
294 | name: 'Mahogany',
295 | rgb: '(205, 74, 76)'
296 | },
297 | {
298 | hex: '#EDD19C',
299 | name: 'Maize',
300 | rgb: '(237, 209, 156)'
301 | },
302 | {
303 | hex: '#979AAA',
304 | name: 'Manatee',
305 | rgb: '(151, 154, 170)'
306 | },
307 | {
308 | hex: '#FF8243',
309 | name: 'Mango Tango',
310 | rgb: '(255, 130, 67)'
311 | },
312 | {
313 | hex: '#C8385A',
314 | name: 'Maroon',
315 | rgb: '(200, 56, 90)'
316 | },
317 | {
318 | hex: '#EF98AA',
319 | name: 'Mauvelous',
320 | rgb: '(239, 152, 170)'
321 | },
322 | {
323 | hex: '#FDBCB4',
324 | name: 'Melon',
325 | rgb: '(253, 188, 180)'
326 | },
327 | {
328 | hex: '#1A4876',
329 | name: 'Midnight Blue',
330 | rgb: '(26, 72, 118)'
331 | },
332 | {
333 | hex: '#30BA8F',
334 | name: 'Mountain Meadow',
335 | rgb: '(48, 186, 143)'
336 | },
337 | {
338 | hex: '#C54B8C',
339 | name: 'Mulberry',
340 | rgb: '(197, 75, 140)'
341 | },
342 | {
343 | hex: '#1974D2',
344 | name: 'Navy Blue',
345 | rgb: '(25, 116, 210)'
346 | },
347 | {
348 | hex: '#FFA343',
349 | name: 'Neon Carrot',
350 | rgb: '(255, 163, 67)'
351 | },
352 | {
353 | hex: '#BAB86C',
354 | name: 'Olive Green',
355 | rgb: '(186, 184, 108)'
356 | },
357 | {
358 | hex: '#FF7538',
359 | name: 'Orange',
360 | rgb: '(255, 117, 56)'
361 | },
362 | {
363 | hex: '#FF2B2B',
364 | name: 'Orange Red',
365 | rgb: '(255, 43, 43)'
366 | },
367 | {
368 | hex: '#F8D568',
369 | name: 'Orange Yellow',
370 | rgb: '(248, 213, 104)'
371 | },
372 | {
373 | hex: '#E6A8D7',
374 | name: 'Orchid',
375 | rgb: '(230, 168, 215)'
376 | },
377 | {
378 | hex: '#414A4C',
379 | name: 'Outer Space',
380 | rgb: '(65, 74, 76)'
381 | },
382 | {
383 | hex: '#FF6E4A',
384 | name: 'Outrageous Orange',
385 | rgb: '(255, 110, 74)'
386 | },
387 | {
388 | hex: '#1CA9C9',
389 | name: 'Pacific Blue',
390 | rgb: '(28, 169, 201)'
391 | },
392 | {
393 | hex: '#FFCFAB',
394 | name: 'Peach',
395 | rgb: '(255, 207, 171)'
396 | },
397 | {
398 | hex: '#C5D0E6',
399 | name: 'Periwinkle',
400 | rgb: '(197, 208, 230)'
401 | },
402 | {
403 | hex: '#FDDDE6',
404 | name: 'Piggy Pink',
405 | rgb: '(253, 221, 230)'
406 | },
407 | {
408 | hex: '#158078',
409 | name: 'Pine Green',
410 | rgb: '(21, 128, 120)'
411 | },
412 | {
413 | hex: '#FC74FD',
414 | name: 'Pink Flamingo',
415 | rgb: '(252, 116, 253)'
416 | },
417 | {
418 | hex: '#F78FA7',
419 | name: 'Pink Sherbet',
420 | rgb: '(247, 143, 167)'
421 | },
422 | {
423 | hex: '#8E4585',
424 | name: 'Plum',
425 | rgb: '(142, 69, 133)'
426 | },
427 | {
428 | hex: '#7442C8',
429 | name: 'Purple Heart',
430 | rgb: '(116, 66, 200)'
431 | },
432 | {
433 | hex: '#9D81BA',
434 | name: "Purple Mountain's Majesty",
435 | rgb: '(157, 129, 186)'
436 | },
437 | {
438 | hex: '#FE4EDA',
439 | name: 'Purple Pizzazz',
440 | rgb: '(254, 78, 218)'
441 | },
442 | {
443 | hex: '#FF496C',
444 | name: 'Radical Red',
445 | rgb: '(255, 73, 108)'
446 | },
447 | {
448 | hex: '#D68A59',
449 | name: 'Raw Sienna',
450 | rgb: '(214, 138, 89)'
451 | },
452 | {
453 | hex: '#714B23',
454 | name: 'Raw Umber',
455 | rgb: '(113, 75, 35)'
456 | },
457 | {
458 | hex: '#FF48D0',
459 | name: 'Razzle Dazzle Rose',
460 | rgb: '(255, 72, 208)'
461 | },
462 | {
463 | hex: '#E3256B',
464 | name: 'Razzmatazz',
465 | rgb: '(227, 37, 107)'
466 | },
467 | {
468 | hex: '#EE204D',
469 | name: 'Red',
470 | rgb: '(238,32 ,77 )'
471 | },
472 | {
473 | hex: '#FF5349',
474 | name: 'Red Orange',
475 | rgb: '(255, 83, 73)'
476 | },
477 | {
478 | hex: '#C0448F',
479 | name: 'Red Violet',
480 | rgb: '(192, 68, 143)'
481 | },
482 | {
483 | hex: '#1FCECB',
484 | name: "Robin's Egg Blue",
485 | rgb: '(31, 206, 203)'
486 | },
487 | {
488 | hex: '#7851A9',
489 | name: 'Royal Purple',
490 | rgb: '(120, 81, 169)'
491 | },
492 | {
493 | hex: '#FF9BAA',
494 | name: 'Salmon',
495 | rgb: '(255, 155, 170)'
496 | },
497 | {
498 | hex: '#FC2847',
499 | name: 'Scarlet',
500 | rgb: '(252, 40, 71)'
501 | },
502 | {
503 | hex: '#76FF7A',
504 | name: "Screamin' Green",
505 | rgb: '(118, 255, 122)'
506 | },
507 | {
508 | hex: '#9FE2BF',
509 | name: 'Sea Green',
510 | rgb: '(159, 226, 191)'
511 | },
512 | {
513 | hex: '#A5694F',
514 | name: 'Sepia',
515 | rgb: '(165, 105, 79)'
516 | },
517 | {
518 | hex: '#8A795D',
519 | name: 'Shadow',
520 | rgb: '(138, 121, 93)'
521 | },
522 | {
523 | hex: '#45CEA2',
524 | name: 'Shamrock',
525 | rgb: '(69, 206, 162)'
526 | },
527 | {
528 | hex: '#FB7EFD',
529 | name: 'Shocking Pink',
530 | rgb: '(251, 126, 253)'
531 | },
532 | {
533 | hex: '#CDC5C2',
534 | name: 'Silver',
535 | rgb: '(205, 197, 194)'
536 | },
537 | {
538 | hex: '#80DAEB',
539 | name: 'Sky Blue',
540 | rgb: '(128, 218, 235)'
541 | },
542 | {
543 | hex: '#ECEABE',
544 | name: 'Spring Green',
545 | rgb: '(236, 234, 190)'
546 | },
547 | {
548 | hex: '#FFCF48',
549 | name: 'Sunglow',
550 | rgb: '(255, 207, 72)'
551 | },
552 | {
553 | hex: '#FD5E53',
554 | name: 'Sunset Orange',
555 | rgb: '(253, 94, 83)'
556 | },
557 | {
558 | hex: '#FAA76C',
559 | name: 'Tan',
560 | rgb: '(250, 167, 108)'
561 | },
562 | {
563 | hex: '#18A7B5',
564 | name: 'Teal Blue',
565 | rgb: '(24, 167, 181)'
566 | },
567 | {
568 | hex: '#EBC7DF',
569 | name: 'Thistle',
570 | rgb: '(235, 199, 223)'
571 | },
572 | {
573 | hex: '#FC89AC',
574 | name: 'Tickle Me Pink',
575 | rgb: '(252, 137, 172)'
576 | },
577 | {
578 | hex: '#DBD7D2',
579 | name: 'Timberwolf',
580 | rgb: '(219, 215, 210)'
581 | },
582 | {
583 | hex: '#17806D',
584 | name: 'Tropical Rain Forest',
585 | rgb: '(23, 128, 109)'
586 | },
587 | {
588 | hex: '#DEAA88',
589 | name: 'Tumbleweed',
590 | rgb: '(222, 170, 136)'
591 | },
592 | {
593 | hex: '#77DDE7',
594 | name: 'Turquoise Blue',
595 | rgb: '(119, 221, 231)'
596 | },
597 | {
598 | hex: '#FFFF66',
599 | name: 'Unmellow Yellow',
600 | rgb: '(255, 255, 102)'
601 | },
602 | {
603 | hex: '#926EAE',
604 | name: 'Violet (Purple)',
605 | rgb: '(146, 110, 174)'
606 | },
607 | {
608 | hex: '#324AB2',
609 | name: 'Violet Blue',
610 | rgb: '(50, 74, 178)'
611 | },
612 | {
613 | hex: '#F75394',
614 | name: 'Violet Red',
615 | rgb: '(247, 83, 148)'
616 | },
617 | {
618 | hex: '#FFA089',
619 | name: 'Vivid Tangerine',
620 | rgb: '(255, 160, 137)'
621 | },
622 | {
623 | hex: '#8F509D',
624 | name: 'Vivid Violet',
625 | rgb: '(143, 80, 157)'
626 | },
627 | {
628 | hex: '#FFFFFF',
629 | name: 'White',
630 | rgb: '(255, 255, 255)'
631 | },
632 | {
633 | hex: '#A2ADD0',
634 | name: 'Wild Blue Yonder',
635 | rgb: '(162, 173, 208)'
636 | },
637 | {
638 | hex: '#FF43A4',
639 | name: 'Wild Strawberry',
640 | rgb: '(255, 67, 164)'
641 | },
642 | {
643 | hex: '#FC6C85',
644 | name: 'Wild Watermelon',
645 | rgb: '(252, 108, 133)'
646 | },
647 | {
648 | hex: '#CDA4DE',
649 | name: 'Wisteria',
650 | rgb: '(205, 164, 222)'
651 | },
652 | {
653 | hex: '#FCE883',
654 | name: 'Yellow',
655 | rgb: '(252, 232, 131)'
656 | },
657 | {
658 | hex: '#C5E384',
659 | name: 'Yellow Green',
660 | rgb: '(197, 227, 132)'
661 | },
662 | {
663 | hex: '#FFAE42',
664 | name: 'Yellow Orange',
665 | rgb: '(255, 174, 66)'
666 | }
667 | ];
668 |
--------------------------------------------------------------------------------
/src/pretty.js:
--------------------------------------------------------------------------------
1 | import {
2 | encodeEntities,
3 | indent,
4 | isLargeString,
5 | styleObjToCss,
6 | getChildren,
7 | createComponent,
8 | UNSAFE_NAME,
9 | VOID_ELEMENTS,
10 | NAMESPACE_REPLACE_REGEX,
11 | SVG_CAMEL_CASE,
12 | HTML_ENUMERATED,
13 | HTML_LOWER_CASE,
14 | getContext,
15 | setDirty,
16 | isDirty,
17 | unsetDirty
18 | } from './lib/util.js';
19 | import { COMMIT, DIFF, DIFFED, RENDER, SKIP_EFFECTS } from './lib/constants.js';
20 | import { options, Fragment } from 'preact';
21 |
22 | // components without names, kept as a hash for later comparison to return consistent UnnamedComponentXX names.
23 | const UNNAMED = [];
24 |
25 | const EMPTY_ARR = [];
26 | const EMPTY_STR = '';
27 | const PRESERVE_WHITESPACE_TAGS = new Set(['pre', 'textarea']);
28 |
29 | /**
30 | * Render Preact JSX + Components to a pretty-printed HTML-like string.
31 | * @param {VNode} vnode JSX Element / VNode to render
32 | * @param {Object} [context={}] Initial root context object
33 | * @param {Object} [opts={}] Rendering options
34 | * @param {Boolean} [opts.shallow=false] Serialize nested Components (` `) instead of rendering
35 | * @param {Boolean} [opts.xml=false] Use self-closing tags for elements without children
36 | * @param {Boolean} [opts.pretty=false] Add whitespace for readability
37 | * @param {RegExp|undefined} [opts.voidElements] RegeEx to define which element types are self-closing
38 | * @param {boolean} [_inner]
39 | * @returns {String} a pretty-printed HTML-like string
40 | */
41 | export default function renderToStringPretty(vnode, context, opts, _inner) {
42 | // Performance optimization: `renderToString` is synchronous and we
43 | // therefore don't execute any effects. To do that we pass an empty
44 | // array to `options._commit` (`__c`). But we can go one step further
45 | // and avoid a lot of dirty checks and allocations by setting
46 | // `options._skipEffects` (`__s`) too.
47 | const previousSkipEffects = options[SKIP_EFFECTS];
48 | options[SKIP_EFFECTS] = true;
49 |
50 | try {
51 | return _renderToStringPretty(vnode, context || {}, opts, _inner);
52 | } finally {
53 | // options._commit, we don't schedule any effects in this library right now,
54 | // so we can pass an empty queue to this hook.
55 | if (options[COMMIT]) options[COMMIT](vnode, EMPTY_ARR);
56 | options[SKIP_EFFECTS] = previousSkipEffects;
57 | EMPTY_ARR.length = 0;
58 | }
59 | }
60 |
61 | function _renderToStringPretty(
62 | vnode,
63 | context,
64 | opts,
65 | inner,
66 | isSvgMode,
67 | selectValue
68 | ) {
69 | if (vnode == null || typeof vnode === 'boolean') {
70 | return '';
71 | }
72 |
73 | // #text nodes
74 | if (typeof vnode !== 'object') {
75 | if (typeof vnode === 'function') return '';
76 | return encodeEntities(vnode + '');
77 | }
78 |
79 | let pretty = opts.pretty,
80 | indentChar = pretty && typeof pretty === 'string' ? pretty : '\t';
81 |
82 | if (Array.isArray(vnode)) {
83 | let rendered = '';
84 | for (let i = 0; i < vnode.length; i++) {
85 | if (pretty && i > 0) rendered = rendered + '\n';
86 | rendered =
87 | rendered +
88 | _renderToStringPretty(
89 | vnode[i],
90 | context,
91 | opts,
92 | inner,
93 | isSvgMode,
94 | selectValue
95 | );
96 | }
97 | return rendered;
98 | }
99 |
100 | // VNodes have {constructor:undefined} to prevent JSON injection:
101 | if (vnode.constructor !== undefined) return '';
102 |
103 | if (options[DIFF]) options[DIFF](vnode);
104 |
105 | let nodeName = vnode.type,
106 | props = vnode.props,
107 | isComponent = false;
108 |
109 | // components
110 | if (typeof nodeName === 'function') {
111 | isComponent = true;
112 | if (
113 | opts.shallow &&
114 | (inner || opts.renderRootComponent === false) &&
115 | nodeName !== Fragment
116 | ) {
117 | nodeName = getComponentName(nodeName);
118 | } else if (nodeName === Fragment) {
119 | const children = [];
120 | getChildren(children, vnode.props.children);
121 | return _renderToStringPretty(
122 | children,
123 | context,
124 | opts,
125 | opts.shallowHighOrder !== false,
126 | isSvgMode,
127 | selectValue
128 | );
129 | } else {
130 | let rendered;
131 |
132 | let c = (vnode.__c = createComponent(vnode, context));
133 |
134 | let renderHook = options[RENDER];
135 |
136 | if (
137 | !nodeName.prototype ||
138 | typeof nodeName.prototype.render !== 'function'
139 | ) {
140 | let cctx = getContext(nodeName, context);
141 |
142 | // If a hook invokes setState() to invalidate the component during rendering,
143 | // re-render it up to 25 times to allow "settling" of memoized states.
144 | // Note:
145 | // This will need to be updated for Preact 11 to use internal.flags rather than component._dirty:
146 | // https://github.com/preactjs/preact/blob/d4ca6fdb19bc715e49fd144e69f7296b2f4daa40/src/diff/component.js#L35-L44
147 | let count = 0;
148 | while (isDirty(c) && count++ < 25) {
149 | unsetDirty(c);
150 |
151 | if (renderHook) renderHook(vnode);
152 |
153 | // stateless functional components
154 | rendered = nodeName.call(vnode.__c, props, cctx);
155 | }
156 | } else {
157 | let cctx = getContext(nodeName, context);
158 |
159 | // c = new nodeName(props, context);
160 | c = vnode.__c = new nodeName(props, cctx);
161 | c.__v = vnode;
162 | // turn off stateful re-rendering:
163 | setDirty(c);
164 | c.props = props;
165 | if (c.state == null) c.state = {};
166 |
167 | if (c._nextState == null && c.__s == null) {
168 | c._nextState = c.__s = c.state;
169 | }
170 |
171 | c.context = cctx;
172 | if (nodeName.getDerivedStateFromProps)
173 | c.state = Object.assign(
174 | {},
175 | c.state,
176 | nodeName.getDerivedStateFromProps(c.props, c.state)
177 | );
178 | else if (c.componentWillMount) {
179 | c.componentWillMount();
180 |
181 | // If the user called setState in cWM we need to flush pending,
182 | // state updates. This is the same behaviour in React.
183 | c.state =
184 | c._nextState !== c.state
185 | ? c._nextState
186 | : c.__s !== c.state
187 | ? c.__s
188 | : c.state;
189 | }
190 |
191 | if (renderHook) renderHook(vnode);
192 |
193 | rendered = c.render(c.props, c.state, c.context);
194 | }
195 |
196 | if (c.getChildContext) {
197 | context = Object.assign({}, context, c.getChildContext());
198 | }
199 |
200 | const res = _renderToStringPretty(
201 | rendered,
202 | context,
203 | opts,
204 | opts.shallowHighOrder !== false,
205 | isSvgMode,
206 | selectValue
207 | );
208 |
209 | if (options[DIFFED]) options[DIFFED](vnode);
210 |
211 | return res;
212 | }
213 | }
214 |
215 | // render JSX to HTML
216 | let s = '<' + nodeName,
217 | propChildren,
218 | html;
219 |
220 | const shouldPreserveWhitespace =
221 | pretty &&
222 | typeof nodeName === 'string' &&
223 | PRESERVE_WHITESPACE_TAGS.has(nodeName);
224 |
225 | if (props) {
226 | let attrs = Object.keys(props);
227 |
228 | // allow sorting lexicographically for more determinism (useful for tests, such as via preact-jsx-chai)
229 | if (opts && opts.sortAttributes === true) attrs.sort();
230 |
231 | for (let i = 0; i < attrs.length; i++) {
232 | let name = attrs[i],
233 | v = props[name];
234 | if (name === 'children') {
235 | propChildren = v;
236 | continue;
237 | }
238 |
239 | if (UNSAFE_NAME.test(name)) continue;
240 |
241 | if (
242 | !(opts && opts.allAttributes) &&
243 | (name === 'key' ||
244 | name === 'ref' ||
245 | name === '__self' ||
246 | name === '__source')
247 | )
248 | continue;
249 |
250 | if (name === 'defaultValue') {
251 | name = 'value';
252 | } else if (name === 'defaultChecked') {
253 | name = 'checked';
254 | } else if (name === 'defaultSelected') {
255 | name = 'selected';
256 | } else if (name === 'className') {
257 | if (typeof props.class !== 'undefined') continue;
258 | name = 'class';
259 | } else if (name === 'acceptCharset') {
260 | name = 'accept-charset';
261 | } else if (name === 'httpEquiv') {
262 | name = 'http-equiv';
263 | } else if (NAMESPACE_REPLACE_REGEX.test(name)) {
264 | name = name.replace(NAMESPACE_REPLACE_REGEX, '$1:$2').toLowerCase();
265 | } else if (
266 | (name.at(4) === '-' || HTML_ENUMERATED.has(name)) &&
267 | v != null
268 | ) {
269 | v = v + EMPTY_STR;
270 | } else if (isSvgMode) {
271 | if (SVG_CAMEL_CASE.test(name)) {
272 | name =
273 | name === 'panose1'
274 | ? 'panose-1'
275 | : name.replace(/([A-Z])/g, '-$1').toLowerCase();
276 | }
277 | } else if (HTML_LOWER_CASE.test(name)) {
278 | name = name.toLowerCase();
279 | }
280 |
281 | if (name === 'htmlFor') {
282 | if (props.for) continue;
283 | name = 'for';
284 | }
285 |
286 | if (name === 'style' && v && typeof v === 'object') {
287 | v = styleObjToCss(v);
288 | }
289 |
290 | // always use string values instead of booleans for aria attributes
291 | // also see https://github.com/preactjs/preact/pull/2347/files
292 | if (name[0] === 'a' && name['1'] === 'r' && typeof v === 'boolean') {
293 | v = String(v);
294 | }
295 |
296 | let hooked =
297 | opts.attributeHook &&
298 | opts.attributeHook(name, v, context, opts, isComponent);
299 | if (hooked || hooked === '') {
300 | s = s + hooked;
301 | continue;
302 | }
303 |
304 | if (name === 'dangerouslySetInnerHTML') {
305 | html = v && v.__html;
306 | } else if (nodeName === 'textarea' && name === 'value') {
307 | //