├── 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 | `; 53 | } 54 | 55 | /** 56 | * @param {string} id 57 | * @param {string} content 58 | * @returns {string} 59 | */ 60 | export function createSubtree(id, content) { 61 | return ``; 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
value is: {value}
; 16 | } 17 | } 18 | ClassConsumer.contextType = Ctx; 19 | 20 | let rendered = renderJsx( 21 | 22 | 23 | 24 | ); 25 | 26 | expect(rendered).to.equal(dedent` 27 |
value is: correct
28 | `); 29 | }); 30 | 31 | it('should support createContext', () => { 32 | const { Provider, Consumer } = createContext(); 33 | let rendered = renderJsx( 34 | 35 | {(value) =>
value is: {value}
}
36 |
37 | ); 38 | 39 | expect(rendered).to.equal(dedent` 40 |
value is: correct
41 | `); 42 | }); 43 | 44 | it('should support nested Providers', () => { 45 | const { Provider, Consumer } = createContext(); 46 | let rendered = renderJsx( 47 | 48 | 49 | {(value) =>
value is: {value}
}
50 |
51 |
52 | ); 53 | 54 | expect(rendered).to.equal(dedent` 55 |
value is: correct
56 | `); 57 | }); 58 | 59 | it('should support falsy context value', () => { 60 | const { Provider, Consumer } = createContext(); 61 | let rendered = renderJsx( 62 | 63 | {(value) =>
value is: {value}
}
64 |
65 | ); 66 | 67 | expect(rendered).to.equal(dedent` 68 |
value is:
69 | `); 70 | }); 71 | 72 | it('should support default context value with absent provider', () => { 73 | const { Consumer } = createContext('correct'); 74 | let rendered = renderJsx( 75 | {(value) =>
value is: {value}
}
76 | ); 77 | 78 | expect(rendered).to.equal(dedent` 79 |
value is: correct
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 | '' 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 | '' 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(''); 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 | [![NPM](http://img.shields.io/npm/v/preact-render-to-string.svg)](https://www.npmjs.com/package/preact-render-to-string) 4 | [![Build status](https://github.com/preactjs/preact-render-to-string/actions/workflows/ci.yml/badge.svg)](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 | 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 | '' 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 | '' 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 | '' 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 | '

id: P0-0

loading...
', 146 | '' 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 | '' 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 | '' 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 | `
foobar

hello

` 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\t

hello

\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\thi\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(`
\n\t
A
\n\t
B
\n
`); 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 |
foobar{' '}
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 = `
  • one
  • two
  • three
`; 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 = `
  • one
  • two
  • three
`; 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 = `
  • one
  • two
  • three
  • four
`; 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 = `
  • one
  • two
  • three
  • four
`; 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 = `
  • one
  • two
  • three
`; 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 = `
  • one
  • two
  • three
`; 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
  • foo
  • bar
'; 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 | // 308 | propChildren = v; 309 | } else if ((v || v === 0 || v === '') && typeof v !== 'function') { 310 | if (v === true || v === '') { 311 | v = name; 312 | // in non-xml mode, allow boolean attributes 313 | if (!opts || !opts.xml) { 314 | s = s + ' ' + name; 315 | continue; 316 | } 317 | } 318 | 319 | if (name === 'value') { 320 | if (nodeName === 'select') { 321 | selectValue = v; 322 | continue; 323 | } else if ( 324 | // If we're looking at an