Skip to content

Commit

Permalink
Merge pull request #4177 from preactjs/jsxssr
Browse files Browse the repository at this point in the history
  • Loading branch information
marvinhagemeister committed Nov 7, 2023
2 parents 046af87 + 975779a commit 99709ae
Show file tree
Hide file tree
Showing 5 changed files with 277 additions and 2 deletions.
10 changes: 10 additions & 0 deletions jsx-runtime/src/index.d.ts
Expand Up @@ -47,4 +47,14 @@ export function jsxDEV<P>(
key?: string
): VNode<any>;

// These are not expected to be used manually, but by a JSX transform
export function jsxTemplate(
template: string[],
...expressions: any[]
): VNode<any>;
export function jsxAttr(name: string, value: any): string | null;
export function jsxEscape<T>(
value: T
): string | null | VNode<any> | Array<string | null | VNode>;

export { JSXInternal as JSX };
111 changes: 110 additions & 1 deletion jsx-runtime/src/index.js
@@ -1,9 +1,13 @@
import { options, Fragment } from 'preact';
import { encodeEntities } from './utils';
import { IS_NON_DIMENSIONAL } from '../../src/constants';

/** @typedef {import('preact').VNode} VNode */

let vnodeId = 0;

const isArray = Array.isArray;

/**
* @fileoverview
* This file exports various methods that implement Babel's "automatic" JSX runtime API:
Expand Down Expand Up @@ -71,9 +75,114 @@ function createVNode(type, props, key, isStaticChildren, __source, __self) {
return vnode;
}

/**
* Create a template vnode. This function is not expected to be
* used directly, but rather through a precompile JSX transform
* @param {string[]} templates
* @param {Array<string | null | VNode>} exprs
* @returns {VNode}
*/
function jsxTemplate(templates, ...exprs) {
const vnode = createVNode(Fragment, { tpl: templates, exprs });
// Bypass render to string top level Fragment optimization
vnode.key = vnode._vnode;
return vnode;
}

const JS_TO_CSS = {};
const CSS_REGEX = /[A-Z]/g;

/**
* Serialize an HTML attribute to a string. This function is not
* expected to be used directly, but rather through a precompile
* JSX transform
* @param {string} name The attribute name
* @param {*} value The attribute value
* @returns {string}
*/
function jsxAttr(name, value) {
if (options.attr) {
const result = options.attr(name, value);
if (typeof result === 'string') return result;
}

if (name === 'ref' || name === 'key') return '';
if (name === 'style' && typeof value === 'object') {
let str = '';
for (let prop in value) {
let val = value[prop];
if (val != null && val !== '') {
const name =
prop[0] == '-'
? prop
: JS_TO_CSS[prop] ||
(JS_TO_CSS[prop] = prop.replace(CSS_REGEX, '-$&').toLowerCase());

let suffix = ';';
if (
typeof val === 'number' &&
// Exclude custom-attributes
!name.startsWith('--') &&
!IS_NON_DIMENSIONAL.test(name)
) {
suffix = 'px;';
}
str = str + name + ':' + val + suffix;
}
}
return name + '="' + str + '"';
}

if (
value == null ||
value === false ||
typeof value === 'function' ||
typeof value === 'object'
) {
return '';
} else if (value === true) return name;

return name + '="' + encodeEntities(value) + '"';
}

/**
* Escape a dynamic child passed to `jsxTemplate`. This function
* is not expected to be used directly, but rather through a
* precompile JSX transform
* @param {*} value
* @returns {string | null | VNode | Array<string | null | VNode>}
*/
function jsxEscape(value) {
if (
value == null ||
typeof value === 'boolean' ||
typeof value === 'function'
) {
return null;
}

if (typeof value === 'object') {
// Check for VNode
if (value.constructor === undefined) return value;

if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
value[i] = jsxEscape(value[i]);
}
return value;
}
}

return encodeEntities('' + value);
}

export {
createVNode as jsx,
createVNode as jsxs,
createVNode as jsxDEV,
Fragment
Fragment,
// precompiled JSX transform
jsxTemplate,
jsxAttr,
jsxEscape
};
36 changes: 36 additions & 0 deletions jsx-runtime/src/utils.js
@@ -0,0 +1,36 @@
const ENCODED_ENTITIES = /["&<]/;

/** @param {string} str */
export function encodeEntities(str) {
// Skip all work for strings with no entities needing encoding:
if (str.length === 0 || ENCODED_ENTITIES.test(str) === false) return str;

let last = 0,
i = 0,
out = '',
ch = '';

// Seek forward in str until the next entity char:
for (; i < str.length; i++) {
switch (str.charCodeAt(i)) {
case 34:
ch = '&quot;';
break;
case 38:
ch = '&amp;';
break;
case 60:
ch = '&lt;';
break;
default:
continue;
}
// Append skipped/buffered characters and the encoded entity:
if (i !== last) out += str.slice(last, i);
out += ch;
// Start the next seek/buffer after the entity's offset:
last = i + 1;
}
if (i !== last) out += str.slice(last, i);
return out;
}
116 changes: 115 additions & 1 deletion jsx-runtime/test/browser/jsx-runtime.test.js
@@ -1,6 +1,15 @@
import { Component, createElement, createRef, options } from 'preact';
import { jsx, jsxs, jsxDEV, Fragment } from 'preact/jsx-runtime';
import {
jsx,
jsxs,
jsxDEV,
Fragment,
jsxAttr,
jsxTemplate,
jsxEscape
} from 'preact/jsx-runtime';
import { setupScratch, teardown } from '../../../test/_util/helpers';
import { encodeEntities } from 'preact/jsx-runtime/src/utils';

describe('Babel jsx/jsxDEV', () => {
let scratch;
Expand Down Expand Up @@ -101,3 +110,108 @@ describe('Babel jsx/jsxDEV', () => {
expect(options.vnode).to.have.been.calledWith(vnode);
});
});

describe('encodeEntities', () => {
it('should encode', () => {
expect(encodeEntities("&<'")).to.equal("&amp;&lt;'");
});
});

describe('precompiled JSX', () => {
describe('jsxAttr', () => {
beforeEach(() => {
options.attr = undefined;
});

afterEach(() => {
options.attr = undefined;
});

it('should render simple values', () => {
expect(jsxAttr('foo', 'bar')).to.equal('foo="bar"');
});

it('should render boolean values', () => {
expect(jsxAttr('foo', true)).to.equal('foo');
expect(jsxAttr('foo', false)).to.equal('');
});

it('should ignore invalid values', () => {
expect(jsxAttr('foo', false)).to.equal('');
expect(jsxAttr('foo', null)).to.equal('');
expect(jsxAttr('foo', undefined)).to.equal('');
expect(jsxAttr('foo', () => null)).to.equal('');
expect(jsxAttr('foo', [])).to.equal('');
expect(jsxAttr('key', 'foo')).to.equal('');
expect(jsxAttr('ref', 'foo')).to.equal('');
});

it('should escape values', () => {
expect(jsxAttr('foo', "&<'")).to.equal('foo="&amp;&lt;\'"');
});

it('should call options.attr()', () => {
options.attr = (name, value) => {
return `data-${name}="foo${value}"`;
};

expect(jsxAttr('foo', 'bar')).to.equal('data-foo="foobar"');
});

it('should serialize style object', () => {
expect(jsxAttr('style', { padding: 3 })).to.equal('style="padding:3px;"');
});
});

describe('jsxTemplate', () => {
it('should construct basic template vnode', () => {
const tpl = [`<div>foo</div>`];
const vnode = jsxTemplate(tpl);
expect(vnode.props.tpl).to.equal(tpl);
expect(vnode.type).to.equal(Fragment);
expect(vnode.key).not.to.equal(null);
});

it('should constructe template vnode with expressions', () => {
const tpl = [`<div>foo`, '</div>'];
const vnode = jsxTemplate(tpl, 'bar');
expect(vnode.props.tpl).to.equal(tpl);
expect(vnode.props.exprs).to.deep.equal(['bar']);
expect(vnode.type).to.equal(Fragment);
expect(vnode.key).not.to.equal(null);
});
});

describe('jsxEscape', () => {
it('should escape string children', () => {
expect(jsxEscape('foo')).to.equal('foo');
expect(jsxEscape(2)).to.equal('2');
expect(jsxEscape('&"<')).to.equal('&amp;&quot;&lt;');
expect(jsxEscape(null)).to.equal(null);
expect(jsxEscape(undefined)).to.equal(null);
expect(jsxEscape(true)).to.equal(null);
expect(jsxEscape(false)).to.equal(null);
});

it("should leave VNode's as is", () => {
const vnode = jsx('div', null);
expect(jsxEscape(vnode)).to.equal(vnode);
});

it('should escape arrays', () => {
const vnode = jsx('div', null);
expect(
jsxEscape([vnode, 'foo&"<', null, undefined, true, false, 2, 'foo'])
).to.deep.equal([
vnode,
'foo&amp;&quot;&lt;',
null,
null,
null,
null,
'2',
'foo'
]);
});
});
});
6 changes: 6 additions & 0 deletions src/index.d.ts
Expand Up @@ -349,6 +349,12 @@ export interface Options {
_addHookName?(name: string | number): void;
__suspenseDidResolve?(vnode: VNode, cb: () => void): void;
// __canSuspenseResolve?(vnode: VNode, cb: () => void): void;

/**
* Customize attribute serialization when a precompiled JSX transform
* is used.
*/
attr?(name: string, value: any): string | void;
}

export const options: Options;
Expand Down

0 comments on commit 99709ae

Please sign in to comment.