From 0ea80f58b8483c7d5c06d4e315648250c1939cc8 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 28 Oct 2023 20:10:10 +0900 Subject: [PATCH 01/31] feat(jsx): Support async component. --- src/context.test.ts | 16 ++++++++-------- src/context.ts | 32 ++++++++++++++++++++++++-------- src/helper/html/index.ts | 20 +++++++++++++++++--- src/hono.test.ts | 2 +- src/jsx/index.test.tsx | 15 +++++++++++++++ src/jsx/index.ts | 24 +++++++++++++++++------- src/utils/html.ts | 2 +- 7 files changed, 83 insertions(+), 28 deletions(-) diff --git a/src/context.test.ts b/src/context.test.ts index 77aacd64f..84956bf57 100644 --- a/src/context.test.ts +++ b/src/context.test.ts @@ -27,7 +27,7 @@ describe('Context', () => { }) it('c.html()', async () => { - const res = c.html('

Hello! Hono!

', 201, { 'X-Custom': 'Message' }) + const res = await c.html('

Hello! Hono!

', 201, { 'X-Custom': 'Message' }) expect(res.status).toBe(201) expect(res.headers.get('Content-Type')).toMatch('text/html') expect(await res.text()).toBe('

Hello! Hono!

') @@ -60,7 +60,7 @@ describe('Context', () => { it('c.header() - append, c.html()', async () => { c.header('X-Foo', 'Bar', { append: true }) - const res = c.html('

This rendered fine

') + const res = await c.html('

This rendered fine

') expect(res.headers.get('content-type')).toMatch(/^text\/html/) }) @@ -189,11 +189,11 @@ describe('Context header', () => { }) it('Should return only one content-type value', async () => { c.header('Content-Type', 'foo') - const res = c.html('foo') + const res = await c.html('foo') expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') }) it('Should rewrite header values correctly', async () => { - c.res = c.html('foo') + c.res = await c.html('foo') const res = c.text('foo') expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/) }) @@ -257,7 +257,7 @@ describe('Pass a ResponseInit to respond methods', () => { it('c.html()', async () => { const originalResponse = new Response('foo') - const res = c.html('

foo

', originalResponse) + const res = await c.html('

foo

', originalResponse) expect(res.headers.get('content-type')).toMatch(/^text\/html/) expect(await res.text()).toBe('

foo

') }) @@ -306,7 +306,7 @@ describe('Pass a ResponseInit to respond methods', () => { declare module './context' { interface ContextRenderer { - (content: string, head: { title: string }): Response + (content: string | Promise, head: { title: string }): Response | Promise } } @@ -319,7 +319,7 @@ describe('c.render', () => { it('Should return a Response from the default renderer', async () => { c.header('foo', 'bar') - const res = c.render('

content

', { title: 'dummy ' }) + const res = await c.render('

content

', { title: 'dummy ' }) expect(res.headers.get('foo')).toBe('bar') expect(await res.text()).toBe('

content

') }) @@ -329,7 +329,7 @@ describe('c.render', () => { return c.html(`${head.title}${content}`) }) c.header('foo', 'bar') - const res = c.render('

content

', { title: 'title' }) + const res = await c.render('

content

', { title: 'title' }) expect(res.headers.get('foo')).toBe('bar') expect(await res.text()).toBe('title

content

') }) diff --git a/src/context.ts b/src/context.ts index 4ca04362a..7c670a0c3 100644 --- a/src/context.ts +++ b/src/context.ts @@ -19,7 +19,7 @@ export interface ContextVariableMap {} export interface ContextRenderer {} interface DefaultRenderer { - (content: string): Response | Promise + (content: string | Promise): Response | Promise } export type Renderer = ContextRenderer extends Function ? ContextRenderer : DefaultRenderer @@ -75,8 +75,10 @@ interface JSONTRespond { } interface HTMLRespond { - (html: string, status?: StatusCode, headers?: HeaderRecord): Response - (html: string, init?: ResponseInit): Response + (html: string | Promise, status?: StatusCode, headers?: HeaderRecord): + | Response + | Promise + (html: string | Promise, init?: ResponseInit): Response | Promise } type ContextOptions = { @@ -106,7 +108,7 @@ export class Context< private _pH: Record | undefined = undefined // _preparedHeaders private _res: Response | undefined private _init = true - private _renderer: Renderer = (content: string) => this.html(content) + private _renderer: Renderer = (content: string | Promise) => this.html(content) private notFoundHandler: NotFoundHandler = () => new Response() constructor(req: HonoRequest, options?: ContextOptions) { @@ -356,15 +358,29 @@ export class Context< } html: HTMLRespond = ( - html: string, + html: string | Promise, arg?: StatusCode | ResponseInit, headers?: HeaderRecord - ): Response => { + ): Response | Promise => { this._pH ??= {} this._pH['content-type'] = 'text/html; charset=UTF-8' + + if (typeof html === 'object') { + if (!(html instanceof Promise)) { + html = (html as string).toString() // HtmlEscapedString object to string + } + if ((html as string | Promise) instanceof Promise) { + return (html as unknown as Promise).then((html) => { + return typeof arg === 'number' + ? this.newResponse(html, arg, headers) + : this.newResponse(html, arg) + }) + } + } + return typeof arg === 'number' - ? this.newResponse(html, arg, headers) - : this.newResponse(html, arg) + ? this.newResponse(html as string, arg, headers) + : this.newResponse(html as string, arg) } redirect = (location: string, status: StatusCode = 302): Response => { diff --git a/src/helper/html/index.ts b/src/helper/html/index.ts index 748b2d0a0..8abc1f0eb 100644 --- a/src/helper/html/index.ts +++ b/src/helper/html/index.ts @@ -8,7 +8,10 @@ export const raw = (value: unknown): HtmlEscapedString => { return escapedString } -export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlEscapedString => { +export const html = ( + strings: TemplateStringsArray, + ...values: unknown[] +): HtmlEscapedString | Promise => { const buffer: StringBuffer = [''] for (let i = 0, len = strings.length - 1; i < len; i++) { @@ -27,7 +30,12 @@ export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlE (typeof child === 'object' && (child as HtmlEscaped).isEscaped) || typeof child === 'number' ) { - buffer[0] += child + const tmp = child.toString() + if (tmp instanceof Promise) { + buffer.unshift('', tmp) + } else { + buffer[0] += tmp + } } else { escapeToBuffer(child.toString(), buffer) } @@ -35,5 +43,11 @@ export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlE } buffer[0] += strings[strings.length - 1] - return raw(buffer[0]) + return buffer.length === 1 + ? raw(buffer[0]) + : Promise.all(buffer.reverse()).then((res) => + raw( + res.map((r) => (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r)).join('') + ) + ) } diff --git a/src/hono.test.ts b/src/hono.test.ts index 3d0e7ccbe..29b15cc71 100644 --- a/src/hono.test.ts +++ b/src/hono.test.ts @@ -2409,7 +2409,7 @@ describe('HEAD method', () => { declare module './context' { interface ContextRenderer { - (content: string, head: { title: string }): Response + (content: string | Promise, head: { title: string }): Response | Promise } } diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index f1e2d2b6d..aea46235c 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -67,6 +67,21 @@ describe('JSX middleware', () => { `) }) + + it('Should render async component', async () => { + const AsyncComponent = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return

Hello from async component

+ } + + app.get('/', (c) => { + return c.html() + }) + const res = await app.request('http://localhost/') + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') + expect(await res.text()).toBe('

Hello from async component

') + }) }) describe('render to string', () => { diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 5010a8c45..5e7ed2164 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -8,7 +8,7 @@ type Props = Record declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { - type Element = HtmlEscapedString + type Element = HtmlEscapedString | Promise interface ElementChildrenAttribute { children: Child } @@ -76,7 +76,9 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void typeof child === 'number' || (child as unknown as { isEscaped: boolean }).isEscaped ) { - buffer[0] += child + ;(buffer[0] as string) += child + } else if (child instanceof Promise) { + buffer.unshift('', child) } else { // `child` type is `Child[]`, so stringify recursively childrenToStringToBuffer(child, buffer) @@ -84,7 +86,7 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void } } -type Child = string | number | JSXNode | Child[] +type Child = string | Promise | number | JSXNode | Child[] export class JSXNode implements HtmlEscaped { tag: string | Function props: Props @@ -96,10 +98,14 @@ export class JSXNode implements HtmlEscaped { this.children = children } - toString(): string { + toString(): string | Promise { const buffer: StringBuffer = [''] this.toStringToBuffer(buffer) - return buffer[0] + return buffer.length === 1 + ? buffer[0] + : Promise.all(buffer.reverse()).then((res) => + res.map((r) => (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r)).join('') + ) } toStringToBuffer(buffer: StringBuffer): void { @@ -172,7 +178,9 @@ class JSXFunctionNode extends JSXNode { children: children.length <= 1 ? children[0] : children, }) - if (res instanceof JSXNode) { + if (res instanceof Promise) { + buffer.unshift('', res) + } else if (res instanceof JSXNode) { res.toStringToBuffer(buffer) } else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) { buffer[0] += res @@ -201,7 +209,9 @@ const jsxFn = ( } } -export type FC = (props: T & { children?: Child }) => HtmlEscapedString +export type FC = ( + props: T & { children?: Child } +) => HtmlEscapedString | Promise const shallowEqual = (a: Props, b: Props): boolean => { if (a === b) { diff --git a/src/utils/html.ts b/src/utils/html.ts index 51a22cb5c..13b830a72 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -1,6 +1,6 @@ export type HtmlEscaped = { isEscaped: true } export type HtmlEscapedString = string & HtmlEscaped -export type StringBuffer = [string] +export type StringBuffer = (string | Promise)[] // The `escapeToBuffer` implementation is based on code from the MIT licensed `react-dom` package. // https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/server/escapeTextForBrowser.js From 0d5e4e04c94b85e30482db1bf68127c0c03fac09 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 28 Oct 2023 21:43:43 +0900 Subject: [PATCH 02/31] chore: denoify --- deno_dist/context.ts | 32 ++++++++++++++++++++++++-------- deno_dist/helper/html/index.ts | 20 +++++++++++++++++--- deno_dist/jsx/index.ts | 24 +++++++++++++++++------- deno_dist/utils/html.ts | 2 +- 4 files changed, 59 insertions(+), 19 deletions(-) diff --git a/deno_dist/context.ts b/deno_dist/context.ts index 3e3d87ec9..7304d51c5 100644 --- a/deno_dist/context.ts +++ b/deno_dist/context.ts @@ -19,7 +19,7 @@ export interface ContextVariableMap {} export interface ContextRenderer {} interface DefaultRenderer { - (content: string): Response | Promise + (content: string | Promise): Response | Promise } export type Renderer = ContextRenderer extends Function ? ContextRenderer : DefaultRenderer @@ -75,8 +75,10 @@ interface JSONTRespond { } interface HTMLRespond { - (html: string, status?: StatusCode, headers?: HeaderRecord): Response - (html: string, init?: ResponseInit): Response + (html: string | Promise, status?: StatusCode, headers?: HeaderRecord): + | Response + | Promise + (html: string | Promise, init?: ResponseInit): Response | Promise } type ContextOptions = { @@ -106,7 +108,7 @@ export class Context< private _pH: Record | undefined = undefined // _preparedHeaders private _res: Response | undefined private _init = true - private _renderer: Renderer = (content: string) => this.html(content) + private _renderer: Renderer = (content: string | Promise) => this.html(content) private notFoundHandler: NotFoundHandler = () => new Response() constructor(req: HonoRequest, options?: ContextOptions) { @@ -356,15 +358,29 @@ export class Context< } html: HTMLRespond = ( - html: string, + html: string | Promise, arg?: StatusCode | ResponseInit, headers?: HeaderRecord - ): Response => { + ): Response | Promise => { this._pH ??= {} this._pH['content-type'] = 'text/html; charset=UTF-8' + + if (typeof html === 'object') { + if (!(html instanceof Promise)) { + html = (html as string).toString() // HtmlEscapedString object to string + } + if ((html as string | Promise) instanceof Promise) { + return (html as unknown as Promise).then((html) => { + return typeof arg === 'number' + ? this.newResponse(html, arg, headers) + : this.newResponse(html, arg) + }) + } + } + return typeof arg === 'number' - ? this.newResponse(html, arg, headers) - : this.newResponse(html, arg) + ? this.newResponse(html as string, arg, headers) + : this.newResponse(html as string, arg) } redirect = (location: string, status: StatusCode = 302): Response => { diff --git a/deno_dist/helper/html/index.ts b/deno_dist/helper/html/index.ts index 82ad4dd2f..913c41152 100644 --- a/deno_dist/helper/html/index.ts +++ b/deno_dist/helper/html/index.ts @@ -8,7 +8,10 @@ export const raw = (value: unknown): HtmlEscapedString => { return escapedString } -export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlEscapedString => { +export const html = ( + strings: TemplateStringsArray, + ...values: unknown[] +): HtmlEscapedString | Promise => { const buffer: StringBuffer = [''] for (let i = 0, len = strings.length - 1; i < len; i++) { @@ -27,7 +30,12 @@ export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlE (typeof child === 'object' && (child as HtmlEscaped).isEscaped) || typeof child === 'number' ) { - buffer[0] += child + const tmp = child.toString() + if (tmp instanceof Promise) { + buffer.unshift('', tmp) + } else { + buffer[0] += tmp + } } else { escapeToBuffer(child.toString(), buffer) } @@ -35,5 +43,11 @@ export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlE } buffer[0] += strings[strings.length - 1] - return raw(buffer[0]) + return buffer.length === 1 + ? raw(buffer[0]) + : Promise.all(buffer.reverse()).then((res) => + raw( + res.map((r) => (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r)).join('') + ) + ) } diff --git a/deno_dist/jsx/index.ts b/deno_dist/jsx/index.ts index 4fdaced07..3adc4385e 100644 --- a/deno_dist/jsx/index.ts +++ b/deno_dist/jsx/index.ts @@ -8,7 +8,7 @@ type Props = Record declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { - type Element = HtmlEscapedString + type Element = HtmlEscapedString | Promise interface ElementChildrenAttribute { children: Child } @@ -76,7 +76,9 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void typeof child === 'number' || (child as unknown as { isEscaped: boolean }).isEscaped ) { - buffer[0] += child + ;(buffer[0] as string) += child + } else if (child instanceof Promise) { + buffer.unshift('', child) } else { // `child` type is `Child[]`, so stringify recursively childrenToStringToBuffer(child, buffer) @@ -84,7 +86,7 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void } } -type Child = string | number | JSXNode | Child[] +type Child = string | Promise | number | JSXNode | Child[] export class JSXNode implements HtmlEscaped { tag: string | Function props: Props @@ -96,10 +98,14 @@ export class JSXNode implements HtmlEscaped { this.children = children } - toString(): string { + toString(): string | Promise { const buffer: StringBuffer = [''] this.toStringToBuffer(buffer) - return buffer[0] + return buffer.length === 1 + ? buffer[0] + : Promise.all(buffer.reverse()).then((res) => + res.map((r) => (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r)).join('') + ) } toStringToBuffer(buffer: StringBuffer): void { @@ -172,7 +178,9 @@ class JSXFunctionNode extends JSXNode { children: children.length <= 1 ? children[0] : children, }) - if (res instanceof JSXNode) { + if (res instanceof Promise) { + buffer.unshift('', res) + } else if (res instanceof JSXNode) { res.toStringToBuffer(buffer) } else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) { buffer[0] += res @@ -201,7 +209,9 @@ const jsxFn = ( } } -export type FC = (props: T & { children?: Child }) => HtmlEscapedString +export type FC = ( + props: T & { children?: Child } +) => HtmlEscapedString | Promise const shallowEqual = (a: Props, b: Props): boolean => { if (a === b) { diff --git a/deno_dist/utils/html.ts b/deno_dist/utils/html.ts index 51a22cb5c..13b830a72 100644 --- a/deno_dist/utils/html.ts +++ b/deno_dist/utils/html.ts @@ -1,6 +1,6 @@ export type HtmlEscaped = { isEscaped: true } export type HtmlEscapedString = string & HtmlEscaped -export type StringBuffer = [string] +export type StringBuffer = (string | Promise)[] // The `escapeToBuffer` implementation is based on code from the MIT licensed `react-dom` package. // https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/server/escapeTextForBrowser.js From 31d59bb8cc925ac3dcb67697106810aa72fe8b4d Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 30 Oct 2023 05:53:07 +0900 Subject: [PATCH 03/31] feat: Support nested async components. --- src/helper/html/index.ts | 10 +- src/jsx/index.test.tsx | 14 ++- src/jsx/index.ts | 8 +- src/utils/html.ts | 9 ++ src/utils/url-with-pr.ts | 219 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 src/utils/url-with-pr.ts diff --git a/src/helper/html/index.ts b/src/helper/html/index.ts index 8abc1f0eb..483621d37 100644 --- a/src/helper/html/index.ts +++ b/src/helper/html/index.ts @@ -1,4 +1,4 @@ -import { escapeToBuffer } from '../../utils/html' +import { escapeToBuffer, stringBufferToString } from '../../utils/html' import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../../utils/html' export const raw = (value: unknown): HtmlEscapedString => { @@ -43,11 +43,5 @@ export const html = ( } buffer[0] += strings[strings.length - 1] - return buffer.length === 1 - ? raw(buffer[0]) - : Promise.all(buffer.reverse()).then((res) => - raw( - res.map((r) => (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r)).join('') - ) - ) + return buffer.length === 1 ? raw(buffer[0]) : stringBufferToString(buffer).then((str) => raw(str)) } diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index aea46235c..6b7805e7d 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -69,9 +69,19 @@ describe('JSX middleware', () => { }) it('Should render async component', async () => { + const ChildAsyncComponent = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return child async component + } + const AsyncComponent = async () => { await new Promise((resolve) => setTimeout(resolve, 10)) - return

Hello from async component

+ return ( +

+ Hello from async component + +

+ ) } app.get('/', (c) => { @@ -80,7 +90,7 @@ describe('JSX middleware', () => { const res = await app.request('http://localhost/') expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') - expect(await res.text()).toBe('

Hello from async component

') + expect(await res.text()).toBe('

Hello from async componentchild async component

') }) }) diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 5e7ed2164..65cdd99aa 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -1,4 +1,4 @@ -import { escapeToBuffer } from '../utils/html' +import { escapeToBuffer, stringBufferToString } from '../utils/html' import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../utils/html' import type { IntrinsicElements as IntrinsicElementsDefined } from './intrinsic-elements' @@ -101,11 +101,7 @@ export class JSXNode implements HtmlEscaped { toString(): string | Promise { const buffer: StringBuffer = [''] this.toStringToBuffer(buffer) - return buffer.length === 1 - ? buffer[0] - : Promise.all(buffer.reverse()).then((res) => - res.map((r) => (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r)).join('') - ) + return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer) } toStringToBuffer(buffer: StringBuffer): void { diff --git a/src/utils/html.ts b/src/utils/html.ts index 13b830a72..60bb69ecc 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -7,6 +7,15 @@ export type StringBuffer = (string | Promise)[] const escapeRe = /[&<>'"]/ +export const stringBufferToString = async (buffer: StringBuffer): Promise => { + let str = '' + for (let i = buffer.length - 1; i >= 0; i--) { + const r = await buffer[i] + str += await (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r) + } + return str +} + export const escapeToBuffer = (str: string, buffer: StringBuffer): void => { const match = str.search(escapeRe) if (match === -1) { diff --git a/src/utils/url-with-pr.ts b/src/utils/url-with-pr.ts new file mode 100644 index 000000000..51a08fb89 --- /dev/null +++ b/src/utils/url-with-pr.ts @@ -0,0 +1,219 @@ +export type Pattern = readonly [string, string, RegExp | true] | '*' + +export const splitPath = (path: string): string[] => { + const paths = path.split('/') + if (paths[0] === '') { + paths.shift() + } + return paths +} + +export const splitRoutingPath = (path: string): string[] => { + const groups: [string, string][] = [] // [mark, original string] + for (let i = 0; ; ) { + let replaced = false + path = path.replace(/\{[^}]+\}/g, (m) => { + const mark = `@\\${i}` + groups[i] = [mark, m] + i++ + replaced = true + return mark + }) + if (!replaced) { + break + } + } + + const paths = path.split('/') + if (paths[0] === '') { + paths.shift() + } + for (let i = groups.length - 1; i >= 0; i--) { + const [mark] = groups[i] + for (let j = paths.length - 1; j >= 0; j--) { + if (paths[j].indexOf(mark) !== -1) { + paths[j] = paths[j].replace(mark, groups[i][1]) + break + } + } + } + + return paths +} + +const patternCache: { [key: string]: Pattern } = {} +export const getPattern = (label: string): Pattern | null => { + // * => wildcard + // :id{[0-9]+} => ([0-9]+) + // :id => (.+) + //const name = '' + + if (label === '*') { + return '*' + } + + const match = label.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/) + if (match) { + if (!patternCache[label]) { + if (match[2]) { + patternCache[label] = [label, match[1], new RegExp('^' + match[2] + '$')] + } else { + patternCache[label] = [label, match[1], true] + } + } + + return patternCache[label] + } + + return null +} + +export const getPath = (url: string, strict: boolean = true): string => { + const queryIndex = url.indexOf('?', 8) + const result = url.substring(url.indexOf('/', 8), queryIndex === -1 ? url.length : queryIndex) + + // if strict routing is false => `/hello/hey/` and `/hello/hey` are treated the same + // default is true + if (strict === false && /.+\/$/.test(result)) { + return result.slice(0, -1) + } + + return result +} + +export const getQueryStringFromURL = (url: string): string => { + const queryIndex = url.indexOf('?', 8) + const result = queryIndex !== -1 ? url.slice(queryIndex + 1) : '' + return result +} + +export const mergePath = (...paths: string[]): string => { + let p: string = '' + let endsWithSlash = false + + for (let path of paths) { + /* ['/hey/','/say'] => ['/hey', '/say'] */ + if (p.endsWith('/')) { + p = p.slice(0, -1) + endsWithSlash = true + } + + /* ['/hey','say'] => ['/hey', '/say'] */ + if (!path.startsWith('/')) { + path = `/${path}` + } + + /* ['/hey/', '/'] => `/hey/` */ + if (path === '/' && endsWithSlash) { + p = `${p}/` + } else if (path !== '/') { + p = `${p}${path}` + } + + /* ['/', '/'] => `/` */ + if (path === '/' && p === '') { + p = '/' + } + } + + return p +} + +export const checkOptionalParameter = (path: string): string[] | null => { + /* + If path is `/api/animals/:type?` it will return: + [`/api/animals`, `/api/animals/:type`] + in other cases it will return null + */ + const match = path.match(/^(.+|)(\/\:[^\/]+)\?$/) + if (!match) return null + + const base = match[1] + const optional = base + match[2] + return [base === '' ? '/' : base.replace(/\/$/, ''), optional] +} + +// Optimized +const _decodeURI = (value: string) => { + if (!/[%+]/.test(value)) { + return value + } + if (value.includes('+')) { + value = value.replace(/\+/g, ' ') + } + return value.includes('%') ? decodeURIComponent(value) : value +} + +const _getQueryParam = ( + url: string, + key?: string, + array?: boolean +): string | undefined | Record | string[] | Record => { + if (key && !/[%+]/.test(key)) { + // optimized for unencoded key + + let keyIndex = url.indexOf(`?${key}`, 8) + if (keyIndex === -1) { + keyIndex = url.indexOf(`&${key}`, 8) + } + while (keyIndex !== -1) { + const trailingKeyCode = url.charCodeAt(keyIndex + key.length + 1) + if (trailingKeyCode === 61) { + const valueIndex = keyIndex + key.length + 2 + const endIndex = url.indexOf('&', valueIndex) + const value = url.slice(valueIndex, endIndex === -1 ? undefined : endIndex) + return _decodeURI(value) + } else if (trailingKeyCode == 38 || isNaN(trailingKeyCode)) { + return '' + } + keyIndex = url.indexOf(`&${key}`, keyIndex) + } + + // fallback to default routine + } + + const results: Record | Record = {} + const encoded = /[%+]/.test(url) + + let keyIndex = url.indexOf('?', 8) + while (keyIndex !== -1) { + const valueIndex = url.indexOf('=', keyIndex) + let name = url.slice(keyIndex + 1, valueIndex === -1 ? undefined : valueIndex) + if (encoded) { + name = _decodeURI(name) + } + + let value + if (valueIndex === -1) { + value = '' + keyIndex = -1 + } else { + keyIndex = url.indexOf('&', valueIndex) + value = url.slice(valueIndex + 1, keyIndex === -1 ? undefined : keyIndex) + value = encoded ? _decodeURI(value) : value + } + + if (array) { + ;((results[name] ??= []) as string[]).push(value) + } else { + results[name] ??= value + } + } + + return key ? results[key] : results +} + +export const getQueryParam: ( + url: string, + key?: string +) => string | undefined | Record = _getQueryParam as ( + url: string, + key?: string +) => string | undefined | Record + +export const getQueryParams = ( + url: string, + key?: string +): string[] | undefined | Record => { + return _getQueryParam(url, key, true) as string[] | undefined | Record +} From 5d357fd51c9ef4333bb631294815a341a38e4011 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 30 Oct 2023 05:53:39 +0900 Subject: [PATCH 04/31] chore: denoify --- deno_dist/helper/html/index.ts | 10 +- deno_dist/jsx/index.ts | 8 +- deno_dist/utils/html.ts | 9 ++ deno_dist/utils/url-with-pr.ts | 219 +++++++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 deno_dist/utils/url-with-pr.ts diff --git a/deno_dist/helper/html/index.ts b/deno_dist/helper/html/index.ts index 913c41152..27f5350ea 100644 --- a/deno_dist/helper/html/index.ts +++ b/deno_dist/helper/html/index.ts @@ -1,4 +1,4 @@ -import { escapeToBuffer } from '../../utils/html.ts' +import { escapeToBuffer, stringBufferToString } from '../../utils/html.ts' import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../../utils/html.ts' export const raw = (value: unknown): HtmlEscapedString => { @@ -43,11 +43,5 @@ export const html = ( } buffer[0] += strings[strings.length - 1] - return buffer.length === 1 - ? raw(buffer[0]) - : Promise.all(buffer.reverse()).then((res) => - raw( - res.map((r) => (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r)).join('') - ) - ) + return buffer.length === 1 ? raw(buffer[0]) : stringBufferToString(buffer).then((str) => raw(str)) } diff --git a/deno_dist/jsx/index.ts b/deno_dist/jsx/index.ts index 3adc4385e..069a1083f 100644 --- a/deno_dist/jsx/index.ts +++ b/deno_dist/jsx/index.ts @@ -1,4 +1,4 @@ -import { escapeToBuffer } from '../utils/html.ts' +import { escapeToBuffer, stringBufferToString } from '../utils/html.ts' import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../utils/html.ts' import type { IntrinsicElements as IntrinsicElementsDefined } from './intrinsic-elements.ts' @@ -101,11 +101,7 @@ export class JSXNode implements HtmlEscaped { toString(): string | Promise { const buffer: StringBuffer = [''] this.toStringToBuffer(buffer) - return buffer.length === 1 - ? buffer[0] - : Promise.all(buffer.reverse()).then((res) => - res.map((r) => (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r)).join('') - ) + return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer) } toStringToBuffer(buffer: StringBuffer): void { diff --git a/deno_dist/utils/html.ts b/deno_dist/utils/html.ts index 13b830a72..60bb69ecc 100644 --- a/deno_dist/utils/html.ts +++ b/deno_dist/utils/html.ts @@ -7,6 +7,15 @@ export type StringBuffer = (string | Promise)[] const escapeRe = /[&<>'"]/ +export const stringBufferToString = async (buffer: StringBuffer): Promise => { + let str = '' + for (let i = buffer.length - 1; i >= 0; i--) { + const r = await buffer[i] + str += await (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r) + } + return str +} + export const escapeToBuffer = (str: string, buffer: StringBuffer): void => { const match = str.search(escapeRe) if (match === -1) { diff --git a/deno_dist/utils/url-with-pr.ts b/deno_dist/utils/url-with-pr.ts new file mode 100644 index 000000000..51a08fb89 --- /dev/null +++ b/deno_dist/utils/url-with-pr.ts @@ -0,0 +1,219 @@ +export type Pattern = readonly [string, string, RegExp | true] | '*' + +export const splitPath = (path: string): string[] => { + const paths = path.split('/') + if (paths[0] === '') { + paths.shift() + } + return paths +} + +export const splitRoutingPath = (path: string): string[] => { + const groups: [string, string][] = [] // [mark, original string] + for (let i = 0; ; ) { + let replaced = false + path = path.replace(/\{[^}]+\}/g, (m) => { + const mark = `@\\${i}` + groups[i] = [mark, m] + i++ + replaced = true + return mark + }) + if (!replaced) { + break + } + } + + const paths = path.split('/') + if (paths[0] === '') { + paths.shift() + } + for (let i = groups.length - 1; i >= 0; i--) { + const [mark] = groups[i] + for (let j = paths.length - 1; j >= 0; j--) { + if (paths[j].indexOf(mark) !== -1) { + paths[j] = paths[j].replace(mark, groups[i][1]) + break + } + } + } + + return paths +} + +const patternCache: { [key: string]: Pattern } = {} +export const getPattern = (label: string): Pattern | null => { + // * => wildcard + // :id{[0-9]+} => ([0-9]+) + // :id => (.+) + //const name = '' + + if (label === '*') { + return '*' + } + + const match = label.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/) + if (match) { + if (!patternCache[label]) { + if (match[2]) { + patternCache[label] = [label, match[1], new RegExp('^' + match[2] + '$')] + } else { + patternCache[label] = [label, match[1], true] + } + } + + return patternCache[label] + } + + return null +} + +export const getPath = (url: string, strict: boolean = true): string => { + const queryIndex = url.indexOf('?', 8) + const result = url.substring(url.indexOf('/', 8), queryIndex === -1 ? url.length : queryIndex) + + // if strict routing is false => `/hello/hey/` and `/hello/hey` are treated the same + // default is true + if (strict === false && /.+\/$/.test(result)) { + return result.slice(0, -1) + } + + return result +} + +export const getQueryStringFromURL = (url: string): string => { + const queryIndex = url.indexOf('?', 8) + const result = queryIndex !== -1 ? url.slice(queryIndex + 1) : '' + return result +} + +export const mergePath = (...paths: string[]): string => { + let p: string = '' + let endsWithSlash = false + + for (let path of paths) { + /* ['/hey/','/say'] => ['/hey', '/say'] */ + if (p.endsWith('/')) { + p = p.slice(0, -1) + endsWithSlash = true + } + + /* ['/hey','say'] => ['/hey', '/say'] */ + if (!path.startsWith('/')) { + path = `/${path}` + } + + /* ['/hey/', '/'] => `/hey/` */ + if (path === '/' && endsWithSlash) { + p = `${p}/` + } else if (path !== '/') { + p = `${p}${path}` + } + + /* ['/', '/'] => `/` */ + if (path === '/' && p === '') { + p = '/' + } + } + + return p +} + +export const checkOptionalParameter = (path: string): string[] | null => { + /* + If path is `/api/animals/:type?` it will return: + [`/api/animals`, `/api/animals/:type`] + in other cases it will return null + */ + const match = path.match(/^(.+|)(\/\:[^\/]+)\?$/) + if (!match) return null + + const base = match[1] + const optional = base + match[2] + return [base === '' ? '/' : base.replace(/\/$/, ''), optional] +} + +// Optimized +const _decodeURI = (value: string) => { + if (!/[%+]/.test(value)) { + return value + } + if (value.includes('+')) { + value = value.replace(/\+/g, ' ') + } + return value.includes('%') ? decodeURIComponent(value) : value +} + +const _getQueryParam = ( + url: string, + key?: string, + array?: boolean +): string | undefined | Record | string[] | Record => { + if (key && !/[%+]/.test(key)) { + // optimized for unencoded key + + let keyIndex = url.indexOf(`?${key}`, 8) + if (keyIndex === -1) { + keyIndex = url.indexOf(`&${key}`, 8) + } + while (keyIndex !== -1) { + const trailingKeyCode = url.charCodeAt(keyIndex + key.length + 1) + if (trailingKeyCode === 61) { + const valueIndex = keyIndex + key.length + 2 + const endIndex = url.indexOf('&', valueIndex) + const value = url.slice(valueIndex, endIndex === -1 ? undefined : endIndex) + return _decodeURI(value) + } else if (trailingKeyCode == 38 || isNaN(trailingKeyCode)) { + return '' + } + keyIndex = url.indexOf(`&${key}`, keyIndex) + } + + // fallback to default routine + } + + const results: Record | Record = {} + const encoded = /[%+]/.test(url) + + let keyIndex = url.indexOf('?', 8) + while (keyIndex !== -1) { + const valueIndex = url.indexOf('=', keyIndex) + let name = url.slice(keyIndex + 1, valueIndex === -1 ? undefined : valueIndex) + if (encoded) { + name = _decodeURI(name) + } + + let value + if (valueIndex === -1) { + value = '' + keyIndex = -1 + } else { + keyIndex = url.indexOf('&', valueIndex) + value = url.slice(valueIndex + 1, keyIndex === -1 ? undefined : keyIndex) + value = encoded ? _decodeURI(value) : value + } + + if (array) { + ;((results[name] ??= []) as string[]).push(value) + } else { + results[name] ??= value + } + } + + return key ? results[key] : results +} + +export const getQueryParam: ( + url: string, + key?: string +) => string | undefined | Record = _getQueryParam as ( + url: string, + key?: string +) => string | undefined | Record + +export const getQueryParams = ( + url: string, + key?: string +): string[] | undefined | Record => { + return _getQueryParam(url, key, true) as string[] | undefined | Record +} From 35d7c4ea4b0a9692fe0d244598208f0c01ffc4e8 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 30 Oct 2023 20:38:26 +0900 Subject: [PATCH 05/31] Remove unintended file from commit. --- deno_dist/utils/url-with-pr.ts | 219 --------------------------------- src/utils/url-with-pr.ts | 219 --------------------------------- 2 files changed, 438 deletions(-) delete mode 100644 deno_dist/utils/url-with-pr.ts delete mode 100644 src/utils/url-with-pr.ts diff --git a/deno_dist/utils/url-with-pr.ts b/deno_dist/utils/url-with-pr.ts deleted file mode 100644 index 51a08fb89..000000000 --- a/deno_dist/utils/url-with-pr.ts +++ /dev/null @@ -1,219 +0,0 @@ -export type Pattern = readonly [string, string, RegExp | true] | '*' - -export const splitPath = (path: string): string[] => { - const paths = path.split('/') - if (paths[0] === '') { - paths.shift() - } - return paths -} - -export const splitRoutingPath = (path: string): string[] => { - const groups: [string, string][] = [] // [mark, original string] - for (let i = 0; ; ) { - let replaced = false - path = path.replace(/\{[^}]+\}/g, (m) => { - const mark = `@\\${i}` - groups[i] = [mark, m] - i++ - replaced = true - return mark - }) - if (!replaced) { - break - } - } - - const paths = path.split('/') - if (paths[0] === '') { - paths.shift() - } - for (let i = groups.length - 1; i >= 0; i--) { - const [mark] = groups[i] - for (let j = paths.length - 1; j >= 0; j--) { - if (paths[j].indexOf(mark) !== -1) { - paths[j] = paths[j].replace(mark, groups[i][1]) - break - } - } - } - - return paths -} - -const patternCache: { [key: string]: Pattern } = {} -export const getPattern = (label: string): Pattern | null => { - // * => wildcard - // :id{[0-9]+} => ([0-9]+) - // :id => (.+) - //const name = '' - - if (label === '*') { - return '*' - } - - const match = label.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/) - if (match) { - if (!patternCache[label]) { - if (match[2]) { - patternCache[label] = [label, match[1], new RegExp('^' + match[2] + '$')] - } else { - patternCache[label] = [label, match[1], true] - } - } - - return patternCache[label] - } - - return null -} - -export const getPath = (url: string, strict: boolean = true): string => { - const queryIndex = url.indexOf('?', 8) - const result = url.substring(url.indexOf('/', 8), queryIndex === -1 ? url.length : queryIndex) - - // if strict routing is false => `/hello/hey/` and `/hello/hey` are treated the same - // default is true - if (strict === false && /.+\/$/.test(result)) { - return result.slice(0, -1) - } - - return result -} - -export const getQueryStringFromURL = (url: string): string => { - const queryIndex = url.indexOf('?', 8) - const result = queryIndex !== -1 ? url.slice(queryIndex + 1) : '' - return result -} - -export const mergePath = (...paths: string[]): string => { - let p: string = '' - let endsWithSlash = false - - for (let path of paths) { - /* ['/hey/','/say'] => ['/hey', '/say'] */ - if (p.endsWith('/')) { - p = p.slice(0, -1) - endsWithSlash = true - } - - /* ['/hey','say'] => ['/hey', '/say'] */ - if (!path.startsWith('/')) { - path = `/${path}` - } - - /* ['/hey/', '/'] => `/hey/` */ - if (path === '/' && endsWithSlash) { - p = `${p}/` - } else if (path !== '/') { - p = `${p}${path}` - } - - /* ['/', '/'] => `/` */ - if (path === '/' && p === '') { - p = '/' - } - } - - return p -} - -export const checkOptionalParameter = (path: string): string[] | null => { - /* - If path is `/api/animals/:type?` it will return: - [`/api/animals`, `/api/animals/:type`] - in other cases it will return null - */ - const match = path.match(/^(.+|)(\/\:[^\/]+)\?$/) - if (!match) return null - - const base = match[1] - const optional = base + match[2] - return [base === '' ? '/' : base.replace(/\/$/, ''), optional] -} - -// Optimized -const _decodeURI = (value: string) => { - if (!/[%+]/.test(value)) { - return value - } - if (value.includes('+')) { - value = value.replace(/\+/g, ' ') - } - return value.includes('%') ? decodeURIComponent(value) : value -} - -const _getQueryParam = ( - url: string, - key?: string, - array?: boolean -): string | undefined | Record | string[] | Record => { - if (key && !/[%+]/.test(key)) { - // optimized for unencoded key - - let keyIndex = url.indexOf(`?${key}`, 8) - if (keyIndex === -1) { - keyIndex = url.indexOf(`&${key}`, 8) - } - while (keyIndex !== -1) { - const trailingKeyCode = url.charCodeAt(keyIndex + key.length + 1) - if (trailingKeyCode === 61) { - const valueIndex = keyIndex + key.length + 2 - const endIndex = url.indexOf('&', valueIndex) - const value = url.slice(valueIndex, endIndex === -1 ? undefined : endIndex) - return _decodeURI(value) - } else if (trailingKeyCode == 38 || isNaN(trailingKeyCode)) { - return '' - } - keyIndex = url.indexOf(`&${key}`, keyIndex) - } - - // fallback to default routine - } - - const results: Record | Record = {} - const encoded = /[%+]/.test(url) - - let keyIndex = url.indexOf('?', 8) - while (keyIndex !== -1) { - const valueIndex = url.indexOf('=', keyIndex) - let name = url.slice(keyIndex + 1, valueIndex === -1 ? undefined : valueIndex) - if (encoded) { - name = _decodeURI(name) - } - - let value - if (valueIndex === -1) { - value = '' - keyIndex = -1 - } else { - keyIndex = url.indexOf('&', valueIndex) - value = url.slice(valueIndex + 1, keyIndex === -1 ? undefined : keyIndex) - value = encoded ? _decodeURI(value) : value - } - - if (array) { - ;((results[name] ??= []) as string[]).push(value) - } else { - results[name] ??= value - } - } - - return key ? results[key] : results -} - -export const getQueryParam: ( - url: string, - key?: string -) => string | undefined | Record = _getQueryParam as ( - url: string, - key?: string -) => string | undefined | Record - -export const getQueryParams = ( - url: string, - key?: string -): string[] | undefined | Record => { - return _getQueryParam(url, key, true) as string[] | undefined | Record -} diff --git a/src/utils/url-with-pr.ts b/src/utils/url-with-pr.ts deleted file mode 100644 index 51a08fb89..000000000 --- a/src/utils/url-with-pr.ts +++ /dev/null @@ -1,219 +0,0 @@ -export type Pattern = readonly [string, string, RegExp | true] | '*' - -export const splitPath = (path: string): string[] => { - const paths = path.split('/') - if (paths[0] === '') { - paths.shift() - } - return paths -} - -export const splitRoutingPath = (path: string): string[] => { - const groups: [string, string][] = [] // [mark, original string] - for (let i = 0; ; ) { - let replaced = false - path = path.replace(/\{[^}]+\}/g, (m) => { - const mark = `@\\${i}` - groups[i] = [mark, m] - i++ - replaced = true - return mark - }) - if (!replaced) { - break - } - } - - const paths = path.split('/') - if (paths[0] === '') { - paths.shift() - } - for (let i = groups.length - 1; i >= 0; i--) { - const [mark] = groups[i] - for (let j = paths.length - 1; j >= 0; j--) { - if (paths[j].indexOf(mark) !== -1) { - paths[j] = paths[j].replace(mark, groups[i][1]) - break - } - } - } - - return paths -} - -const patternCache: { [key: string]: Pattern } = {} -export const getPattern = (label: string): Pattern | null => { - // * => wildcard - // :id{[0-9]+} => ([0-9]+) - // :id => (.+) - //const name = '' - - if (label === '*') { - return '*' - } - - const match = label.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/) - if (match) { - if (!patternCache[label]) { - if (match[2]) { - patternCache[label] = [label, match[1], new RegExp('^' + match[2] + '$')] - } else { - patternCache[label] = [label, match[1], true] - } - } - - return patternCache[label] - } - - return null -} - -export const getPath = (url: string, strict: boolean = true): string => { - const queryIndex = url.indexOf('?', 8) - const result = url.substring(url.indexOf('/', 8), queryIndex === -1 ? url.length : queryIndex) - - // if strict routing is false => `/hello/hey/` and `/hello/hey` are treated the same - // default is true - if (strict === false && /.+\/$/.test(result)) { - return result.slice(0, -1) - } - - return result -} - -export const getQueryStringFromURL = (url: string): string => { - const queryIndex = url.indexOf('?', 8) - const result = queryIndex !== -1 ? url.slice(queryIndex + 1) : '' - return result -} - -export const mergePath = (...paths: string[]): string => { - let p: string = '' - let endsWithSlash = false - - for (let path of paths) { - /* ['/hey/','/say'] => ['/hey', '/say'] */ - if (p.endsWith('/')) { - p = p.slice(0, -1) - endsWithSlash = true - } - - /* ['/hey','say'] => ['/hey', '/say'] */ - if (!path.startsWith('/')) { - path = `/${path}` - } - - /* ['/hey/', '/'] => `/hey/` */ - if (path === '/' && endsWithSlash) { - p = `${p}/` - } else if (path !== '/') { - p = `${p}${path}` - } - - /* ['/', '/'] => `/` */ - if (path === '/' && p === '') { - p = '/' - } - } - - return p -} - -export const checkOptionalParameter = (path: string): string[] | null => { - /* - If path is `/api/animals/:type?` it will return: - [`/api/animals`, `/api/animals/:type`] - in other cases it will return null - */ - const match = path.match(/^(.+|)(\/\:[^\/]+)\?$/) - if (!match) return null - - const base = match[1] - const optional = base + match[2] - return [base === '' ? '/' : base.replace(/\/$/, ''), optional] -} - -// Optimized -const _decodeURI = (value: string) => { - if (!/[%+]/.test(value)) { - return value - } - if (value.includes('+')) { - value = value.replace(/\+/g, ' ') - } - return value.includes('%') ? decodeURIComponent(value) : value -} - -const _getQueryParam = ( - url: string, - key?: string, - array?: boolean -): string | undefined | Record | string[] | Record => { - if (key && !/[%+]/.test(key)) { - // optimized for unencoded key - - let keyIndex = url.indexOf(`?${key}`, 8) - if (keyIndex === -1) { - keyIndex = url.indexOf(`&${key}`, 8) - } - while (keyIndex !== -1) { - const trailingKeyCode = url.charCodeAt(keyIndex + key.length + 1) - if (trailingKeyCode === 61) { - const valueIndex = keyIndex + key.length + 2 - const endIndex = url.indexOf('&', valueIndex) - const value = url.slice(valueIndex, endIndex === -1 ? undefined : endIndex) - return _decodeURI(value) - } else if (trailingKeyCode == 38 || isNaN(trailingKeyCode)) { - return '' - } - keyIndex = url.indexOf(`&${key}`, keyIndex) - } - - // fallback to default routine - } - - const results: Record | Record = {} - const encoded = /[%+]/.test(url) - - let keyIndex = url.indexOf('?', 8) - while (keyIndex !== -1) { - const valueIndex = url.indexOf('=', keyIndex) - let name = url.slice(keyIndex + 1, valueIndex === -1 ? undefined : valueIndex) - if (encoded) { - name = _decodeURI(name) - } - - let value - if (valueIndex === -1) { - value = '' - keyIndex = -1 - } else { - keyIndex = url.indexOf('&', valueIndex) - value = url.slice(valueIndex + 1, keyIndex === -1 ? undefined : keyIndex) - value = encoded ? _decodeURI(value) : value - } - - if (array) { - ;((results[name] ??= []) as string[]).push(value) - } else { - results[name] ??= value - } - } - - return key ? results[key] : results -} - -export const getQueryParam: ( - url: string, - key?: string -) => string | undefined | Record = _getQueryParam as ( - url: string, - key?: string -) => string | undefined | Record - -export const getQueryParams = ( - url: string, - key?: string -): string[] | undefined | Record => { - return _getQueryParam(url, key, true) as string[] | undefined | Record -} From 1f5d521021e1a2e902ec5a33c1f0cd07c796fadb Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 30 Oct 2023 20:52:27 +0900 Subject: [PATCH 06/31] test(jsx): Add test for html tagged template strings. --- src/jsx/index.test.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index 6b7805e7d..ee11e9e74 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -90,7 +90,26 @@ describe('JSX middleware', () => { const res = await app.request('http://localhost/') expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') - expect(await res.text()).toBe('

Hello from async componentchild async component

') + expect(await res.text()).toBe( + '

Hello from async componentchild async component

' + ) + }) + + it('Should render async component with "html" tagged template strings', async () => { + const AsyncComponent = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return

Hello from async component

+ } + + app.get('/', (c) => { + return c.html( + html`${()}` + ) + }) + const res = await app.request('http://localhost/') + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') + expect(await res.text()).toBe('

Hello from async component

') }) }) From 405f7293432f7c58b96320e6e1de6edc30ac1617 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 30 Oct 2023 12:26:45 +0900 Subject: [PATCH 07/31] feat: Introduce streaming API with `Suspense` and `use`. --- src/jsx/streaming.test.tsx | 40 ++++++++++++++++ src/jsx/streaming.ts | 96 ++++++++++++++++++++++++++++++++++++++ src/utils/html.ts | 22 +++++++-- 3 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 src/jsx/streaming.test.tsx create mode 100644 src/jsx/streaming.ts diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx new file mode 100644 index 000000000..3d8983894 --- /dev/null +++ b/src/jsx/streaming.test.tsx @@ -0,0 +1,40 @@ +import type { HtmlEscapedString } from '../utils/html' +import { Suspense, use, renderToReadableStream } from './streaming' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { jsx } from './index' + +describe('Streaming', () => { + it('Suspense / use / renderToReadableStream', async () => { + const delayedContent = new Promise((resolve) => + setTimeout(() => resolve(

Hello

), 10) + ) + const Content = () => { + const content = use(delayedContent) + return content + } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + '

Loading...

', + ``, + ]) + }) +}) diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts new file mode 100644 index 000000000..985189664 --- /dev/null +++ b/src/jsx/streaming.ts @@ -0,0 +1,96 @@ +import type { HtmlEscapedString } from '../utils/html' +import type { FC } from './index' + +const useContexts: any[][] = [] + +let suspenseCounter = 0 +let useCounter = 0 +let currentUseContext: number = 0 +let useIndex: number = 0 + +export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => { + let res + const useContext = createUseContext() + try { + res = children?.toString() || '' + } catch (e) { + const index = suspenseCounter++ + if (e instanceof Promise) { + res = new String( + `${fallback.toString()}` + ) as HtmlEscapedString + res.isEscaped = true + ;(res.promises ||= []).push( + e.then(() => { + setUseContext(useContext) + return `` + }) + ) + } else { + throw e + } + } + return res as HtmlEscapedString +} + +export const createUseContext = (): number => { + const newUseContext = useCounter++ + setUseContext(newUseContext) + return newUseContext +} + +export const setUseContext = (index: number): void => { + useIndex = 0 + currentUseContext = index +} + +export const use = (promise: Promise | (() => Promise)): T => { + useIndex++ + + if (useContexts[currentUseContext]) { + return useContexts[currentUseContext][useIndex - 1] + } + + if (typeof promise === 'function') { + promise = promise() + } + promise.then((res) => ((useContexts[currentUseContext] ||= [])[useIndex - 1] = res)) + + throw promise +} + +const textEncoder = new TextEncoder() +export const renderToReadableStream = ( + str: HtmlEscapedString | Promise +): ReadableStream => { + const reader = new ReadableStream({ + async start(controller) { + const resolved = await str.toString() + controller.enqueue(textEncoder.encode(resolved)) + if (typeof resolved !== 'object' || !(resolved as HtmlEscapedString).promises?.length) { + controller.close() + return + } + + const len = (resolved as HtmlEscapedString).promises?.length + let resolvedCount = 0 + for (const p of (resolved as HtmlEscapedString).promises || []) { + p.then((res) => { + resolvedCount++ + controller.enqueue(textEncoder.encode(res)) + if (resolvedCount === len) { + controller.close() + } + }) + } + }, + }) + return reader +} diff --git a/src/utils/html.ts b/src/utils/html.ts index 60bb69ecc..f4fef4602 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -1,4 +1,4 @@ -export type HtmlEscaped = { isEscaped: true } +export type HtmlEscaped = { isEscaped: true; promises?: Promise[] } export type HtmlEscapedString = string & HtmlEscaped export type StringBuffer = (string | Promise)[] @@ -7,13 +7,25 @@ export type StringBuffer = (string | Promise)[] const escapeRe = /[&<>'"]/ -export const stringBufferToString = async (buffer: StringBuffer): Promise => { +export const stringBufferToString = async (buffer: StringBuffer): Promise => { let str = '' + const promises: Promise[] = [] for (let i = buffer.length - 1; i >= 0; i--) { - const r = await buffer[i] - str += await (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r) + let r = await buffer[i] + if (typeof r === 'object') { + promises.push(...((r as HtmlEscapedString).promises || [])) + } + r = await (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r) + if (typeof r === 'object') { + promises.push(...((r as HtmlEscapedString).promises || [])) + } + str += r } - return str + + const res = new String(str) as HtmlEscapedString + res.isEscaped = true + res.promises = promises + return res } export const escapeToBuffer = (str: string, buffer: StringBuffer): void => { From 2285b0dfccbc91ca0dd96b53a94798cf9aaa13b6 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 30 Oct 2023 12:27:34 +0900 Subject: [PATCH 08/31] chore: denoify --- deno_dist/jsx/streaming.ts | 96 ++++++++++++++++++++++++++++++++++++++ deno_dist/utils/html.ts | 22 +++++++-- 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 deno_dist/jsx/streaming.ts diff --git a/deno_dist/jsx/streaming.ts b/deno_dist/jsx/streaming.ts new file mode 100644 index 000000000..daae131b2 --- /dev/null +++ b/deno_dist/jsx/streaming.ts @@ -0,0 +1,96 @@ +import type { HtmlEscapedString } from '../utils/html.ts' +import type { FC } from './index.ts' + +const useContexts: any[][] = [] + +let suspenseCounter = 0 +let useCounter = 0 +let currentUseContext: number = 0 +let useIndex: number = 0 + +export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => { + let res + const useContext = createUseContext() + try { + res = children?.toString() || '' + } catch (e) { + const index = suspenseCounter++ + if (e instanceof Promise) { + res = new String( + `${fallback.toString()}` + ) as HtmlEscapedString + res.isEscaped = true + ;(res.promises ||= []).push( + e.then(() => { + setUseContext(useContext) + return `` + }) + ) + } else { + throw e + } + } + return res as HtmlEscapedString +} + +export const createUseContext = (): number => { + const newUseContext = useCounter++ + setUseContext(newUseContext) + return newUseContext +} + +export const setUseContext = (index: number): void => { + useIndex = 0 + currentUseContext = index +} + +export const use = (promise: Promise | (() => Promise)): T => { + useIndex++ + + if (useContexts[currentUseContext]) { + return useContexts[currentUseContext][useIndex - 1] + } + + if (typeof promise === 'function') { + promise = promise() + } + promise.then((res) => ((useContexts[currentUseContext] ||= [])[useIndex - 1] = res)) + + throw promise +} + +const textEncoder = new TextEncoder() +export const renderToReadableStream = ( + str: HtmlEscapedString | Promise +): ReadableStream => { + const reader = new ReadableStream({ + async start(controller) { + const resolved = await str.toString() + controller.enqueue(textEncoder.encode(resolved)) + if (typeof resolved !== 'object' || !(resolved as HtmlEscapedString).promises?.length) { + controller.close() + return + } + + const len = (resolved as HtmlEscapedString).promises?.length + let resolvedCount = 0 + for (const p of (resolved as HtmlEscapedString).promises || []) { + p.then((res) => { + resolvedCount++ + controller.enqueue(textEncoder.encode(res)) + if (resolvedCount === len) { + controller.close() + } + }) + } + }, + }) + return reader +} diff --git a/deno_dist/utils/html.ts b/deno_dist/utils/html.ts index 60bb69ecc..f4fef4602 100644 --- a/deno_dist/utils/html.ts +++ b/deno_dist/utils/html.ts @@ -1,4 +1,4 @@ -export type HtmlEscaped = { isEscaped: true } +export type HtmlEscaped = { isEscaped: true; promises?: Promise[] } export type HtmlEscapedString = string & HtmlEscaped export type StringBuffer = (string | Promise)[] @@ -7,13 +7,25 @@ export type StringBuffer = (string | Promise)[] const escapeRe = /[&<>'"]/ -export const stringBufferToString = async (buffer: StringBuffer): Promise => { +export const stringBufferToString = async (buffer: StringBuffer): Promise => { let str = '' + const promises: Promise[] = [] for (let i = buffer.length - 1; i >= 0; i--) { - const r = await buffer[i] - str += await (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r) + let r = await buffer[i] + if (typeof r === 'object') { + promises.push(...((r as HtmlEscapedString).promises || [])) + } + r = await (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r) + if (typeof r === 'object') { + promises.push(...((r as HtmlEscapedString).promises || [])) + } + str += r } - return str + + const res = new String(str) as HtmlEscapedString + res.isEscaped = true + res.promises = promises + return res } export const escapeToBuffer = (str: string, buffer: StringBuffer): void => { From 442538e29eb23ffde27ffbdca7baa59976b46fa3 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 30 Oct 2023 21:22:17 +0900 Subject: [PATCH 09/31] "use" receives only Promise. --- src/jsx/streaming.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index 985189664..6a4e5ca10 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -51,16 +51,13 @@ export const setUseContext = (index: number): void => { currentUseContext = index } -export const use = (promise: Promise | (() => Promise)): T => { +export const use = (promise: Promise): T => { useIndex++ if (useContexts[currentUseContext]) { return useContexts[currentUseContext][useIndex - 1] } - if (typeof promise === 'function') { - promise = promise() - } promise.then((res) => ((useContexts[currentUseContext] ||= [])[useIndex - 1] = res)) throw promise From b1545929dae02a688913ece30c447647a2a41f23 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 30 Oct 2023 22:25:34 +0900 Subject: [PATCH 10/31] feat: Support multiple calls and nested calls to "use". --- src/jsx/index.ts | 2 +- src/jsx/streaming.test.tsx | 82 +++++++++++++++++++++++++++++++++++++- src/jsx/streaming.ts | 43 ++++++++++++++------ 3 files changed, 112 insertions(+), 15 deletions(-) diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 65cdd99aa..0e141deaa 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -86,7 +86,7 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void } } -type Child = string | Promise | number | JSXNode | Child[] +export type Child = string | Promise | number | JSXNode | Child[] export class JSXNode implements HtmlEscaped { tag: string | Function props: Props diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index 3d8983894..e86144319 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -1,7 +1,7 @@ import type { HtmlEscapedString } from '../utils/html' import { Suspense, use, renderToReadableStream } from './streaming' // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { jsx } from './index' +import { jsx, Fragment } from './index' describe('Streaming', () => { it('Suspense / use / renderToReadableStream', async () => { @@ -34,6 +34,86 @@ d=d.getElementById('H:0') d.nextElementSibling.remove() d.replaceWith(c.content) })(document) +`, + ]) + }) + + it('Multiple calls to "use"', async () => { + const delayedContent = new Promise((resolve) => + setTimeout(() => resolve(

Hello

), 10) + ) + const delayedContent2 = new Promise((resolve) => + setTimeout(() => resolve(

World

), 10) + ) + const Content = () => { + const content = use(delayedContent) + const content2 = use(delayedContent2) + return <>{content}{content2} + } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + '

Loading...

', + ``, + ]) + }) + + it('Nested calls to "use"', async () => { + const delayedContent = new Promise((resolve) => + setTimeout(() => resolve(

Hello

), 10) + ) + const delayedContent2 = new Promise((resolve) => + setTimeout(() => resolve(

paragraph

), 10) + ) + + const SubContent = () => { + const content = use(delayedContent2) + return <>{content} + } + const Content = () => { + const content = use(delayedContent) + return <>{content} + } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + '

Loading...

', + ``, ]) }) diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index 6a4e5ca10..3cf50aaeb 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -1,18 +1,36 @@ import type { HtmlEscapedString } from '../utils/html' -import type { FC } from './index' +import type { FC, Child } from './index' const useContexts: any[][] = [] let suspenseCounter = 0 let useCounter = 0 let currentUseContext: number = 0 -let useIndex: number = 0 +let useIndex: number = -1 + +async function childrenToString(useContext: number, children: Child): Promise { + setUseContext(useContext) + try { + return children.toString() + } catch (e) { + if (e instanceof Promise) { + await e + return childrenToString(useContext, children) + } else { + throw e + } + } +} export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => { + if (!children) { + return fallback.toString() + } + let res const useContext = createUseContext() try { - res = children?.toString() || '' + res = children.toString() } catch (e) { const index = suspenseCounter++ if (e instanceof Promise) { @@ -20,10 +38,9 @@ export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => `${fallback.toString()}` ) as HtmlEscapedString res.isEscaped = true - ;(res.promises ||= []).push( - e.then(() => { - setUseContext(useContext) - return `` - }) - ) + }), + ] } else { throw e } @@ -47,18 +64,18 @@ export const createUseContext = (): number => { } export const setUseContext = (index: number): void => { - useIndex = 0 + useIndex = -1 currentUseContext = index } export const use = (promise: Promise): T => { useIndex++ - if (useContexts[currentUseContext]) { - return useContexts[currentUseContext][useIndex - 1] + if (useContexts[currentUseContext]?.[useIndex]) { + return useContexts[currentUseContext][useIndex] } - promise.then((res) => ((useContexts[currentUseContext] ||= [])[useIndex - 1] = res)) + promise.then((res) => ((useContexts[currentUseContext] ||= [])[useIndex] = res)) throw promise } From d7efbab4d58ffeed8e8f131effaaa7b465bf5c03 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 31 Oct 2023 05:35:07 +0900 Subject: [PATCH 11/31] refactor: tweaks replacement script. --- src/jsx/streaming.test.tsx | 18 +++++++++--------- src/jsx/streaming.ts | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index e86144319..256279ed8 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -26,12 +26,12 @@ describe('Streaming', () => { } expect(chunks).toEqual([ - '

Loading...

', + '

Loading...

', ``, @@ -64,12 +64,12 @@ d.replaceWith(c.content) } expect(chunks).toEqual([ - '

Loading...

', + '

Loading...

', ``, @@ -106,12 +106,12 @@ d.replaceWith(c.content) } expect(chunks).toEqual([ - '

Loading...

', + '

Loading...

', ``, diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index 3cf50aaeb..4ebba4886 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -35,16 +35,16 @@ export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => const index = suspenseCounter++ if (e instanceof Promise) { res = new String( - `${fallback.toString()}` + `${fallback.toString()}` ) as HtmlEscapedString res.isEscaped = true res.promises = [ e.then(async () => { return `` From 2d152491162378a5471b76631fd131029155c2dd Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 31 Oct 2023 06:02:21 +0900 Subject: [PATCH 12/31] test: Add test for replacement result of streaming --- package.json | 1 + src/jsx/streaming.test.tsx | 35 +++++++++++++++++++++++---- yarn.lock | 48 +++++++++++++++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index ccc136a87..50e401830 100644 --- a/package.json +++ b/package.json @@ -443,6 +443,7 @@ "eslint-plugin-import": "^2.27.5", "eslint-plugin-node": "^11.1.0", "form-data": "^4.0.0", + "happy-dom": "^12.10.3", "jest": "^29.6.4", "jest-preset-fastly-js-compute": "^1.3.0", "msw": "^1.0.0", diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index 256279ed8..7cd50c589 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -1,8 +1,17 @@ +import { Window } from 'happy-dom' import type { HtmlEscapedString } from '../utils/html' import { Suspense, use, renderToReadableStream } from './streaming' // eslint-disable-next-line @typescript-eslint/no-unused-vars import { jsx, Fragment } from './index' +function replacementResult(html: string) { + const window = new Window() + const document = window.document + document.write(html) + document.querySelectorAll('template, script').forEach((s) => s.remove()) + return document.body.innerHTML +} + describe('Streaming', () => { it('Suspense / use / renderToReadableStream', async () => { const delayedContent = new Promise((resolve) => @@ -20,7 +29,7 @@ describe('Streaming', () => { ) const chunks = [] - const textDecoder = new TextDecoder + const textDecoder = new TextDecoder() for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } @@ -36,6 +45,8 @@ d.replaceWith(c.content) })(document) `, ]) + + expect(replacementResult(chunks.join(''))).toEqual('

Hello

') }) it('Multiple calls to "use"', async () => { @@ -48,7 +59,12 @@ d.replaceWith(c.content) const Content = () => { const content = use(delayedContent) const content2 = use(delayedContent2) - return <>{content}{content2} + return ( + <> + {content} + {content2} + + ) } const stream = renderToReadableStream( @@ -58,7 +74,7 @@ d.replaceWith(c.content) ) const chunks = [] - const textDecoder = new TextDecoder + const textDecoder = new TextDecoder() for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } @@ -74,6 +90,8 @@ d.replaceWith(c.content) })(document) `, ]) + + expect(replacementResult(chunks.join(''))).toEqual('

Hello

World

') }) it('Nested calls to "use"', async () => { @@ -90,7 +108,12 @@ d.replaceWith(c.content) } const Content = () => { const content = use(delayedContent) - return <>{content} + return ( + <> + {content} + + + ) } const stream = renderToReadableStream( @@ -100,7 +123,7 @@ d.replaceWith(c.content) ) const chunks = [] - const textDecoder = new TextDecoder + const textDecoder = new TextDecoder() for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } @@ -116,5 +139,7 @@ d.replaceWith(c.content) })(document) `, ]) + + expect(replacementResult(chunks.join(''))).toEqual('

Hello

paragraph

') }) }) diff --git a/yarn.lock b/yarn.lock index 90fced206..cee852b8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2601,6 +2601,11 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + date-fns@^1.27.2: version "1.30.1" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" @@ -2876,6 +2881,11 @@ enhanced-resolve@^5.12.0: graceful-fs "^4.2.4" tapable "^2.2.0" +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -3993,6 +4003,18 @@ grapheme-splitter@^1.0.4: resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.6.0.tgz#c2dcffa4649db149f6282af726c8c83f1c7c5fdb" integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw== +happy-dom@^12.10.3: + version "12.10.3" + resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-12.10.3.tgz#e61985eff163b822c110458be7f81aa4f94ad588" + integrity sha512-JzUXOh0wdNGY54oKng5hliuBkq/+aT1V3YpTM+lrN/GoLQTANZsMaIvmHiHe612rauHvPJnDZkZ+5GZR++1Abg== + dependencies: + css.escape "^1.5.1" + entities "^4.5.0" + iconv-lite "^0.6.3" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + hard-rejection@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" @@ -4125,6 +4147,13 @@ human-signals@^3.0.1: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-3.0.1.tgz#c740920859dafa50e5a3222da9d3bf4bb0e5eef5" integrity sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ== +iconv-lite@0.6.3, iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -6719,7 +6748,7 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" -"safer-buffer@>= 2.1.2 < 3": +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -7717,6 +7746,23 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" From 04f1c242489ae5c51bd91c0d25d87d1d72a3580a Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 31 Oct 2023 06:09:43 +0900 Subject: [PATCH 13/31] chore: denoify --- deno_dist/jsx/index.ts | 2 +- deno_dist/jsx/streaming.ts | 54 ++++++++++++++++++++++++-------------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/deno_dist/jsx/index.ts b/deno_dist/jsx/index.ts index 069a1083f..8048348fe 100644 --- a/deno_dist/jsx/index.ts +++ b/deno_dist/jsx/index.ts @@ -86,7 +86,7 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void } } -type Child = string | Promise | number | JSXNode | Child[] +export type Child = string | Promise | number | JSXNode | Child[] export class JSXNode implements HtmlEscaped { tag: string | Function props: Props diff --git a/deno_dist/jsx/streaming.ts b/deno_dist/jsx/streaming.ts index daae131b2..10a112da8 100644 --- a/deno_dist/jsx/streaming.ts +++ b/deno_dist/jsx/streaming.ts @@ -1,38 +1,55 @@ import type { HtmlEscapedString } from '../utils/html.ts' -import type { FC } from './index.ts' +import type { FC, Child } from './index.ts' const useContexts: any[][] = [] let suspenseCounter = 0 let useCounter = 0 let currentUseContext: number = 0 -let useIndex: number = 0 +let useIndex: number = -1 + +async function childrenToString(useContext: number, children: Child): Promise { + setUseContext(useContext) + try { + return children.toString() + } catch (e) { + if (e instanceof Promise) { + await e + return childrenToString(useContext, children) + } else { + throw e + } + } +} export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => { + if (!children) { + return fallback.toString() + } + let res const useContext = createUseContext() try { - res = children?.toString() || '' + res = children.toString() } catch (e) { const index = suspenseCounter++ if (e instanceof Promise) { res = new String( - `${fallback.toString()}` + `${fallback.toString()}` ) as HtmlEscapedString res.isEscaped = true - ;(res.promises ||= []).push( - e.then(() => { - setUseContext(useContext) - return `` - }) - ) + }), + ] } else { throw e } @@ -47,21 +64,18 @@ export const createUseContext = (): number => { } export const setUseContext = (index: number): void => { - useIndex = 0 + useIndex = -1 currentUseContext = index } -export const use = (promise: Promise | (() => Promise)): T => { +export const use = (promise: Promise): T => { useIndex++ - if (useContexts[currentUseContext]) { - return useContexts[currentUseContext][useIndex - 1] + if (useContexts[currentUseContext]?.[useIndex]) { + return useContexts[currentUseContext][useIndex] } - if (typeof promise === 'function') { - promise = promise() - } - promise.then((res) => ((useContexts[currentUseContext] ||= [])[useIndex - 1] = res)) + promise.then((res) => ((useContexts[currentUseContext] ||= [])[useIndex] = res)) throw promise } From bd7d682b205b8ea24fd5dd513b797b2a2a205e79 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 31 Oct 2023 06:26:03 +0900 Subject: [PATCH 14/31] test: Add test "Complex fallback content" --- src/jsx/streaming.test.tsx | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index 7cd50c589..1bbd38e60 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -142,4 +142,41 @@ d.replaceWith(c.content) expect(replacementResult(chunks.join(''))).toEqual('

Hello

paragraph

') }) + + it('Complex fallback content', async () => { + const delayedContent = new Promise((resolve) => + setTimeout(() => resolve(

Hello

), 10) + ) + + const Content = () => { + const content = use(delayedContent) + return content + } + + const stream = renderToReadableStream( + Loading...}> + + + ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + 'Loading...', + ``, + ]) + + expect(replacementResult(chunks.join(''))).toEqual('

Hello

') + }) }) From 3d36782921110a3199d7d1792f2cb3892443f40d Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 31 Oct 2023 06:41:48 +0900 Subject: [PATCH 15/31] refactor: Add "typescript-eslint/no-explicit-any". --- deno_dist/jsx/streaming.ts | 2 ++ src/jsx/streaming.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/deno_dist/jsx/streaming.ts b/deno_dist/jsx/streaming.ts index 10a112da8..efd70189c 100644 --- a/deno_dist/jsx/streaming.ts +++ b/deno_dist/jsx/streaming.ts @@ -1,6 +1,7 @@ import type { HtmlEscapedString } from '../utils/html.ts' import type { FC, Child } from './index.ts' +// eslint-disable-next-line @typescript-eslint/no-explicit-any const useContexts: any[][] = [] let suspenseCounter = 0 @@ -22,6 +23,7 @@ async function childrenToString(useContext: number, children: Child): Promise = async ({ children, fallback }) => { if (!children) { return fallback.toString() diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index 4ebba4886..5c3df1a89 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -1,6 +1,7 @@ import type { HtmlEscapedString } from '../utils/html' import type { FC, Child } from './index' +// eslint-disable-next-line @typescript-eslint/no-explicit-any const useContexts: any[][] = [] let suspenseCounter = 0 @@ -22,6 +23,7 @@ async function childrenToString(useContext: number, children: Child): Promise = async ({ children, fallback }) => { if (!children) { return fallback.toString() From ec17ac940b7d1a51c89ebb1f958cdd6831bc2f6f Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 31 Oct 2023 06:36:54 +0900 Subject: [PATCH 16/31] Use jsdom instead of happy-dom due to ci failure. --- package.json | 3 +- src/jsx/streaming.test.tsx | 32 +++-- yarn.lock | 247 ++++++++++++++++++++++++++++++++++--- 3 files changed, 252 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 50e401830..798e01ca3 100644 --- a/package.json +++ b/package.json @@ -424,6 +424,7 @@ "@types/crypto-js": "^4.1.1", "@types/glob": "^8.0.0", "@types/jest": "^29.4.0", + "@types/jsdom": "^21.1.4", "@types/node": "^20.8.2", "@types/node-fetch": "^2.6.2", "@types/supertest": "^2.0.12", @@ -443,9 +444,9 @@ "eslint-plugin-import": "^2.27.5", "eslint-plugin-node": "^11.1.0", "form-data": "^4.0.0", - "happy-dom": "^12.10.3", "jest": "^29.6.4", "jest-preset-fastly-js-compute": "^1.3.0", + "jsdom": "^22.1.0", "msw": "^1.0.0", "node-fetch": "2", "np": "^7.7.0", diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index 1bbd38e60..5038090b4 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -1,14 +1,12 @@ -import { Window } from 'happy-dom' +import { JSDOM } from 'jsdom' import type { HtmlEscapedString } from '../utils/html' import { Suspense, use, renderToReadableStream } from './streaming' // eslint-disable-next-line @typescript-eslint/no-unused-vars import { jsx, Fragment } from './index' function replacementResult(html: string) { - const window = new Window() - const document = window.document - document.write(html) - document.querySelectorAll('template, script').forEach((s) => s.remove()) + const document = new JSDOM(html, { runScripts: 'dangerously' }).window.document + document.querySelectorAll('template, script').forEach((e) => e.remove()) return document.body.innerHTML } @@ -46,7 +44,9 @@ d.replaceWith(c.content) `, ]) - expect(replacementResult(chunks.join(''))).toEqual('

Hello

') + expect(replacementResult(`${chunks.join('')}`)).toEqual( + '

Hello

' + ) }) it('Multiple calls to "use"', async () => { @@ -91,7 +91,9 @@ d.replaceWith(c.content) `, ]) - expect(replacementResult(chunks.join(''))).toEqual('

Hello

World

') + expect(replacementResult(`${chunks.join('')}`)).toEqual( + '

Hello

World

' + ) }) it('Nested calls to "use"', async () => { @@ -140,7 +142,9 @@ d.replaceWith(c.content) `, ]) - expect(replacementResult(chunks.join(''))).toEqual('

Hello

paragraph

') + expect(replacementResult(`${chunks.join('')}`)).toEqual( + '

Hello

paragraph

' + ) }) it('Complex fallback content', async () => { @@ -154,7 +158,13 @@ d.replaceWith(c.content) } const stream = renderToReadableStream( - Loading...}> + + Loading... + + } + > ) @@ -177,6 +187,8 @@ d.replaceWith(c.content) `, ]) - expect(replacementResult(chunks.join(''))).toEqual('

Hello

') + expect(replacementResult(`${chunks.join('')}`)).toEqual( + '

Hello

' + ) }) }) diff --git a/yarn.lock b/yarn.lock index cee852b8c..73cce7c6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1365,6 +1365,11 @@ dependencies: defer-to-connect "^2.0.0" +"@tootallnate/once@2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" + integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== + "@types/babel__core@^7.1.14": version "7.20.1" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.1.tgz#916ecea274b0c776fec721e333e55762d3a9614b" @@ -1506,6 +1511,15 @@ resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz#ba05426a43f9e4e30b631941e0aa17bf0c890ed5" integrity sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g== +"@types/jsdom@^21.1.4": + version "21.1.4" + resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.4.tgz#82105c8fb5a1072265dde1a180336ca74a8fbabf" + integrity sha512-NzAMLEV0KQ4cBaDx3Ls8VfJUElyDUm1xrtYRmcMK0gF8L5xYbujFVaQlJ50yinQ/d47j2rEP1XUzkiYrw4YRFA== + dependencies: + "@types/node" "*" + "@types/tough-cookie" "*" + parse5 "^7.0.0" + "@types/json-buffer@~3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/json-buffer/-/json-buffer-3.0.0.tgz#85c1ff0f0948fc159810d4b5be35bf8c20875f64" @@ -1615,6 +1629,11 @@ dependencies: "@types/superagent" "*" +"@types/tough-cookie@*": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.4.tgz#cf2f0c7c51b985b6afecea73eb2cd65421ecb717" + integrity sha512-95Sfz4nvMAb0Nl9DTxN3j64adfwfbBPEYq14VN7zT5J5O2M9V6iZMIIQU1U+pJyl9agHYHNCqhCXgyEtIRRa5A== + "@types/yargs-parser@*": version "20.2.1" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129" @@ -1781,6 +1800,11 @@ resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b" integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA== +abab@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" + integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -1801,6 +1825,13 @@ acorn@^8.8.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -2601,17 +2632,28 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== -css.escape@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" - integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== +cssstyle@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-3.0.0.tgz#17ca9c87d26eac764bb8cfd00583cff21ce0277a" + integrity sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg== + dependencies: + rrweb-cssom "^0.6.0" + +data-urls@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-4.0.0.tgz#333a454eca6f9a5b7b0f1013ff89074c3f522dd4" + integrity sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g== + dependencies: + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^12.0.0" date-fns@^1.27.2: version "1.30.1" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== -debug@4.3.4, debug@^4.1.0, debug@^4.3.3, debug@^4.3.4: +debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -2645,6 +2687,11 @@ decamelize@^1.1.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== +decimal.js@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" + integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== + decompress-response@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" @@ -2817,6 +2864,13 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +domexception@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" + integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw== + dependencies: + webidl-conversions "^7.0.0" + dot-prop@^5.2.0: version "5.3.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" @@ -2881,7 +2935,7 @@ enhanced-resolve@^5.12.0: graceful-fs "^4.2.4" tapable "^2.2.0" -entities@^4.5.0: +entities@^4.4.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== @@ -4003,18 +4057,6 @@ grapheme-splitter@^1.0.4: resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.6.0.tgz#c2dcffa4649db149f6282af726c8c83f1c7c5fdb" integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw== -happy-dom@^12.10.3: - version "12.10.3" - resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-12.10.3.tgz#e61985eff163b822c110458be7f81aa4f94ad588" - integrity sha512-JzUXOh0wdNGY54oKng5hliuBkq/+aT1V3YpTM+lrN/GoLQTANZsMaIvmHiHe612rauHvPJnDZkZ+5GZR++1Abg== - dependencies: - css.escape "^1.5.1" - entities "^4.5.0" - iconv-lite "^0.6.3" - webidl-conversions "^7.0.0" - whatwg-encoding "^2.0.0" - whatwg-mimetype "^3.0.0" - hard-rejection@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" @@ -4122,6 +4164,13 @@ hosted-git-info@^4.0.1: dependencies: lru-cache "^6.0.0" +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== + dependencies: + whatwg-encoding "^2.0.0" + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -4137,6 +4186,23 @@ http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0: resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== + dependencies: + "@tootallnate/once" "2" + agent-base "6" + debug "4" + +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -4147,7 +4213,7 @@ human-signals@^3.0.1: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-3.0.1.tgz#c740920859dafa50e5a3222da9d3bf4bb0e5eef5" integrity sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ== -iconv-lite@0.6.3, iconv-lite@^0.6.3: +iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -4557,6 +4623,11 @@ is-plain-object@^5.0.0: resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + is-promise@^2.1.0: version "2.2.2" resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" @@ -5195,6 +5266,35 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsdom@^22.1.0: + version "22.1.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-22.1.0.tgz#0fca6d1a37fbeb7f4aac93d1090d782c56b611c8" + integrity sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw== + dependencies: + abab "^2.0.6" + cssstyle "^3.0.0" + data-urls "^4.0.0" + decimal.js "^10.4.3" + domexception "^4.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.4" + parse5 "^7.1.2" + rrweb-cssom "^0.6.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.2" + w3c-xmlserializer "^4.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^12.0.1" + ws "^8.13.0" + xml-name-validator "^4.0.0" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -5960,6 +6060,11 @@ number-is-nan@^1.0.0: resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" integrity sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ== +nwsapi@^2.2.4: + version "2.2.7" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" + integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== + object-assign@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -6260,6 +6365,13 @@ parse-package-name@^1.0.0: resolved "https://registry.yarnpkg.com/parse-package-name/-/parse-package-name-1.0.0.tgz#1a108757e4ffc6889d5e78bcc4932a97c097a5a7" integrity sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg== +parse5@^7.0.0, parse5@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + path-depth@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/path-depth/-/path-depth-1.0.0.tgz#88cf881097e171b8b54d450d2167ea76063d3086" @@ -6412,6 +6524,11 @@ ps-tree@1.2.0: dependencies: event-stream "=3.3.4" +psl@^1.1.33: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + publint@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/publint/-/publint-0.1.8.tgz#a679fccc67578f232ecffa568acfccf3a7c983c5" @@ -6434,6 +6551,11 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +punycode@^2.1.1, punycode@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + pupa@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" @@ -6453,6 +6575,11 @@ qs@^6.11.0: dependencies: side-channel "^1.0.4" +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -6559,6 +6686,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" @@ -6689,6 +6821,11 @@ rollup@^3.27.1: optionalDependencies: fsevents "~2.3.2" +rrweb-cssom@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" + integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== + run-async@^2.2.0, run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -6753,6 +6890,13 @@ safe-regex-test@^1.0.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + scoped-regex@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-2.1.0.tgz#7b9be845d81fd9d21d1ec97c61a0b7cf86d2015f" @@ -7264,6 +7408,11 @@ symbol-observable@^3.0.0: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-3.0.0.tgz#eea8f6478c651018e059044268375c408c15c533" integrity sha512-6tDOXSHiVjuCaasQSWTmHUWn4PuG7qa3+1WT031yTc/swT7+rLiw3GOrFxaH1E3lLP09dH3bVuVDf2gK5rxG3Q== +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + synckit@^0.8.5: version "0.8.5" resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.5.tgz#b7f4358f9bb559437f9f167eb6bc46b3c9818fa3" @@ -7361,6 +7510,23 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +tough-cookie@^4.1.2: + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + +tr46@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" + integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw== + dependencies: + punycode "^2.3.0" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -7550,6 +7716,11 @@ universal-user-agent@^6.0.0: resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + update-browserslist-db@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" @@ -7597,6 +7768,14 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + urlpattern-polyfill@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-4.0.3.tgz#c1fa7a73eb4e6c6a1ffb41b24cf31974f7392d3b" @@ -7707,6 +7886,13 @@ vitest@^0.34.3: vite-node "0.34.3" why-is-node-running "^2.2.2" +w3c-xmlserializer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" + integrity sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw== + dependencies: + xml-name-validator "^4.0.0" + wait-on@6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-6.0.1.tgz#16bbc4d1e4ebdd41c5b4e63a2e16dbd1f4e5601e" @@ -7763,6 +7949,14 @@ whatwg-mimetype@^3.0.0: resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== +whatwg-url@^12.0.0, whatwg-url@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-12.0.1.tgz#fd7bcc71192e7c3a2a97b9a8d6b094853ed8773c" + integrity sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ== + dependencies: + tr46 "^4.1.1" + webidl-conversions "^7.0.0" + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" @@ -7884,6 +8078,11 @@ write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" +ws@^8.13.0: + version "8.14.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" + integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== + ws@^8.2.2: version "8.4.2" resolved "https://registry.yarnpkg.com/ws/-/ws-8.4.2.tgz#18e749868d8439f2268368829042894b6907aa0b" @@ -7894,6 +8093,16 @@ xdg-basedir@^4.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== +xml-name-validator@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" + integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + xxhash-wasm@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/xxhash-wasm/-/xxhash-wasm-1.0.2.tgz#ecc0f813219b727af4d5f3958ca6becee2f2f1ff" From 4535cc1271136a10673a58770f2b45034c33f114 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 31 Oct 2023 07:24:21 +0900 Subject: [PATCH 17/31] test: update test data for suspense. --- src/jsx/streaming.test.tsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index 5038090b4..f355e7195 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -11,6 +11,11 @@ function replacementResult(html: string) { } describe('Streaming', () => { + let suspenseCounter = 0 + afterEach(() => { + suspenseCounter++ + }) + it('Suspense / use / renderToReadableStream', async () => { const delayedContent = new Promise((resolve) => setTimeout(() => resolve(

Hello

), 10) @@ -33,11 +38,11 @@ describe('Streaming', () => { } expect(chunks).toEqual([ - '

Loading...

', + `

Loading...

`, `` diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index f355e7195..fc980a41e 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -43,7 +43,7 @@ describe('Streaming', () => { ((d,c,n) => { c=d.currentScript.previousSibling d=d.getElementById('H:${suspenseCounter}') -while(n=d.nextSibling){n.remove();if(n.nodeType===8&&n.nodeValue==='/$')break} +do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$') d.replaceWith(c.content) })(document) `, @@ -90,7 +90,7 @@ d.replaceWith(c.content) ((d,c,n) => { c=d.currentScript.previousSibling d=d.getElementById('H:${suspenseCounter}') -while(n=d.nextSibling){n.remove();if(n.nodeType===8&&n.nodeValue==='/$')break} +do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$') d.replaceWith(c.content) })(document) `, @@ -141,7 +141,7 @@ d.replaceWith(c.content) ((d,c,n) => { c=d.currentScript.previousSibling d=d.getElementById('H:${suspenseCounter}') -while(n=d.nextSibling){n.remove();if(n.nodeType===8&&n.nodeValue==='/$')break} +do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$') d.replaceWith(c.content) })(document) `, @@ -186,7 +186,7 @@ d.replaceWith(c.content) ((d,c,n) => { c=d.currentScript.previousSibling d=d.getElementById('H:${suspenseCounter}') -while(n=d.nextSibling){n.remove();if(n.nodeType===8&&n.nodeValue==='/$')break} +do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$') d.replaceWith(c.content) })(document) `, diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index f7aa6fbbb..30bfb1d0a 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -46,7 +46,7 @@ export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => ((d,c,n) => { c=d.currentScript.previousSibling d=d.getElementById('H:${index}') -while(n=d.nextSibling){n.remove();if(n.nodeType===8&&n.nodeValue==='/$')break} +do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$') d.replaceWith(c.content) })(document) ` From 582a6faed26e586fb9b5f410d13f925ff38eb2c4 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 31 Oct 2023 09:19:18 +0900 Subject: [PATCH 23/31] pref: Delete unneeded condition --- deno_dist/jsx/streaming.ts | 2 +- src/jsx/streaming.test.tsx | 13 +++++++++++++ src/jsx/streaming.ts | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/deno_dist/jsx/streaming.ts b/deno_dist/jsx/streaming.ts index c5cb190fa..a31054e06 100644 --- a/deno_dist/jsx/streaming.ts +++ b/deno_dist/jsx/streaming.ts @@ -93,7 +93,7 @@ export const renderToReadableStream = ( controller.enqueue(textEncoder.encode(resolved)) let unresolvedCount = (resolved as HtmlEscapedString).promises?.length || 0 - if (typeof resolved !== 'object' || !unresolvedCount) { + if (!unresolvedCount) { controller.close() return } diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index fc980a41e..ab1d5f052 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -196,4 +196,17 @@ d.replaceWith(c.content) '

Hello

' ) }) + + it('renderToReadableStream(str: string)', async () => { + const str = '

Hello

' + const stream = renderToReadableStream(str as HtmlEscapedString) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([str]) + }) }) diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index 30bfb1d0a..843b763d3 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -93,7 +93,7 @@ export const renderToReadableStream = ( controller.enqueue(textEncoder.encode(resolved)) let unresolvedCount = (resolved as HtmlEscapedString).promises?.length || 0 - if (typeof resolved !== 'object' || !unresolvedCount) { + if (!unresolvedCount) { controller.close() return } From 5bb7323e3c48733e46eaf8e53a0353976ecf439a Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 5 Nov 2023 09:49:29 +0900 Subject: [PATCH 24/31] docs(jsx/streaming): Add `@experimental` flag to streaming API. --- src/jsx/streaming.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index 843b763d3..5013612af 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -23,6 +23,11 @@ async function childrenToString(useContext: number, children: Child): Promise = async ({ children, fallback }) => { if (!children) { @@ -71,6 +76,11 @@ const setUseContext = (index: number): void => { currentUseContext = index } +/** + * @experimental + * `use()` is an experimental feature. + * The API might be changed. + */ export const use = (promise: Promise): T => { useIndex++ @@ -84,6 +94,11 @@ export const use = (promise: Promise): T => { } const textEncoder = new TextEncoder() +/** + * @experimental + * `renderToReadableStream()` is an experimental feature. + * The API might be changed. + */ export const renderToReadableStream = ( str: HtmlEscapedString | Promise ): ReadableStream => { From d156c4e9bc66c04963d5f6a1966d4ca6d0e54d2b Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 5 Nov 2023 09:52:00 +0900 Subject: [PATCH 25/31] fix(jsx/streadming): fix loop when using fullfilled Promise with null or undefined. --- src/jsx/streaming.test.tsx | 66 ++++++++++++++++++++++++++++++++++++++ src/jsx/streaming.ts | 2 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index ab1d5f052..218772b85 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -54,6 +54,72 @@ d.replaceWith(c.content) ) }) + it('resolve(undefined)', async () => { + const Content = () => { + const content = use(Promise.resolve(undefined)) + return

{content}

+ } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + `

Loading...

`, + ``, + ]) + + expect(replacementResult(`${chunks.join('')}`)).toEqual('

') + }) + + it('resolve(null)', async () => { + const Content = () => { + const content = use(Promise.resolve(null)) + return

{content}

+ } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + `

Loading...

`, + ``, + ]) + + expect(replacementResult(`${chunks.join('')}`)).toEqual('

') + }) + it('Multiple calls to "use"', async () => { const delayedContent = new Promise((resolve) => setTimeout(() => resolve(

Hello

), 10) diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index 5013612af..c08196d50 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -84,7 +84,7 @@ const setUseContext = (index: number): void => { export const use = (promise: Promise): T => { useIndex++ - if (useContexts[currentUseContext][useIndex]) { + if (useIndex in useContexts[currentUseContext]) { return useContexts[currentUseContext][useIndex] } From b91a87d1b2cb28bfb506381d1d2ecd8a093b9aaa Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 5 Nov 2023 10:01:19 +0900 Subject: [PATCH 26/31] fix(jsx/streaming): Catch unhandledRejection to avoid streaming not being closed. --- src/jsx/streaming.test.tsx | 29 +++++++++++++++++++++++++++++ src/jsx/streaming.ts | 17 +++++++++++------ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index 218772b85..aa741ce90 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -120,6 +120,35 @@ d.replaceWith(c.content) expect(replacementResult(`${chunks.join('')}`)).toEqual('

') }) + // This test should end successfully , but vitest catches the global unhandledRejection and makes an error, so it temporarily skips + it.skip('reject()', async () => { + const Content = () => { + const content = use(Promise.reject()) + return

{content}

+ } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + `

Loading...

`, + '', + ]) + + expect(replacementResult(`${chunks.join('')}`)).toEqual( + '

Loading...

' + ) + }) + it('Multiple calls to "use"', async () => { const delayedContent = new Promise((resolve) => setTimeout(() => resolve(

Hello

), 10) diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index c08196d50..7186a99ae 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -114,12 +114,17 @@ export const renderToReadableStream = ( } for (let i = 0; i < unresolvedCount; i++) { - ;((resolved as HtmlEscapedString).promises as Promise[])[i].then((res) => { - controller.enqueue(textEncoder.encode(res)) - if (!--unresolvedCount) { - controller.close() - } - }) + ;((resolved as HtmlEscapedString).promises as Promise[])[i] + .catch((err) => { + console.trace(err) + return '' + }) + .then((res) => { + controller.enqueue(textEncoder.encode(res)) + if (!--unresolvedCount) { + controller.close() + } + }) } }, }) From fa9487b9b2330bcb86caecac5e4cde8d00ccf3e3 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 5 Nov 2023 10:10:33 +0900 Subject: [PATCH 27/31] chore(jsx/streaming): Add entries for jsx/streaming to package.json. --- package.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/package.json b/package.json index 798e01ca3..8256419b8 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,11 @@ "import": "./dist/jsx/jsx-runtime.js", "require": "./dist/cjs/jsx/jsx-runtime.js" }, + "./jsx/streaming": { + "types": "./dist/types/jsx/streaming.d.ts", + "import": "./dist/jsx/streaming.js", + "require": "./dist/cjs/jsx/streaming.js" + }, "./jsx-renderer": { "types": "./dist/types/middleware/jsx-renderer/index.d.ts", "import": "./dist/middleware/jsx-renderer/index.js", @@ -304,6 +309,9 @@ "jsx/jsx-dev-runtime": [ "./dist/types/jsx/jsx-dev-runtime.d.ts" ], + "jsx/streaming": [ + "./dist/types/jsx/streaming.d.ts" + ], "jsx-renderer": [ "./dist/types/middleware/jsx-renderer" ], From ec12034c0f63923d377867c6fa0e6b14ef48d7f2 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 5 Nov 2023 10:12:49 +0900 Subject: [PATCH 28/31] chore: denoify --- deno_dist/jsx/streaming.ts | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/deno_dist/jsx/streaming.ts b/deno_dist/jsx/streaming.ts index a31054e06..423f79d8b 100644 --- a/deno_dist/jsx/streaming.ts +++ b/deno_dist/jsx/streaming.ts @@ -23,6 +23,11 @@ async function childrenToString(useContext: number, children: Child): Promise = async ({ children, fallback }) => { if (!children) { @@ -71,10 +76,15 @@ const setUseContext = (index: number): void => { currentUseContext = index } +/** + * @experimental + * `use()` is an experimental feature. + * The API might be changed. + */ export const use = (promise: Promise): T => { useIndex++ - if (useContexts[currentUseContext][useIndex]) { + if (useIndex in useContexts[currentUseContext]) { return useContexts[currentUseContext][useIndex] } @@ -84,6 +94,11 @@ export const use = (promise: Promise): T => { } const textEncoder = new TextEncoder() +/** + * @experimental + * `renderToReadableStream()` is an experimental feature. + * The API might be changed. + */ export const renderToReadableStream = ( str: HtmlEscapedString | Promise ): ReadableStream => { @@ -99,12 +114,17 @@ export const renderToReadableStream = ( } for (let i = 0; i < unresolvedCount; i++) { - ;((resolved as HtmlEscapedString).promises as Promise[])[i].then((res) => { - controller.enqueue(textEncoder.encode(res)) - if (!--unresolvedCount) { - controller.close() - } - }) + ;((resolved as HtmlEscapedString).promises as Promise[])[i] + .catch((err) => { + console.trace(err) + return '' + }) + .then((res) => { + controller.enqueue(textEncoder.encode(res)) + if (!--unresolvedCount) { + controller.close() + } + }) } }, }) From f4589b74d7415035b45e4c81a15e57666c59c8d2 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 6 Nov 2023 08:37:57 +0900 Subject: [PATCH 29/31] feat(jsx/streaming): Support the Async Component inside Suspense. --- src/jsx/streaming.test.tsx | 59 +++++++++++++++++++++++++++++++++++++- src/jsx/streaming.ts | 13 ++++++--- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx index aa741ce90..1f4cd9c72 100644 --- a/src/jsx/streaming.test.tsx +++ b/src/jsx/streaming.test.tsx @@ -16,7 +16,64 @@ describe('Streaming', () => { suspenseCounter++ }) - it('Suspense / use / renderToReadableStream', async () => { + it('Suspense / renderToReadableStream', async () => { + const Content = () => { + const content = new Promise((resolve) => + setTimeout(() => resolve(

Hello

), 10) + ) + return content + } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + `

Loading...

`, + ``, + ]) + + expect(replacementResult(`${chunks.join('')}`)).toEqual( + '

Hello

' + ) + }) + + it('simple content inside Suspense', async () => { + const Content = () => { + return

Hello

+ } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual(['

Hello

']) + }) + + it('use()', async () => { const delayedContent = new Promise((resolve) => setTimeout(() => resolve(

Hello

), 10) ) diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index 7186a99ae..efec3271a 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -39,14 +39,21 @@ export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => try { res = children.toString() } catch (e) { - const index = suspenseCounter++ if (e instanceof Promise) { + res = e + } else { + throw e + } + } finally { + const index = suspenseCounter++ + if (res instanceof Promise) { + const promise = res res = new String( `${fallback.toString()}` ) as HtmlEscapedString res.isEscaped = true res.promises = [ - e.then(async () => { + promise.then(async () => { return `` }), ] - } else { - throw e } } return res as HtmlEscapedString From 979be851b58b85ac4d5cf54b2f40277dc879d0c6 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 6 Nov 2023 08:44:10 +0900 Subject: [PATCH 30/31] chore: denoify --- deno_dist/jsx/streaming.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/deno_dist/jsx/streaming.ts b/deno_dist/jsx/streaming.ts index 423f79d8b..dd5a13af1 100644 --- a/deno_dist/jsx/streaming.ts +++ b/deno_dist/jsx/streaming.ts @@ -39,14 +39,21 @@ export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => try { res = children.toString() } catch (e) { - const index = suspenseCounter++ if (e instanceof Promise) { + res = e + } else { + throw e + } + } finally { + const index = suspenseCounter++ + if (res instanceof Promise) { + const promise = res res = new String( `${fallback.toString()}` ) as HtmlEscapedString res.isEscaped = true res.promises = [ - e.then(async () => { + promise.then(async () => { return `` }), ] - } else { - throw e } } return res as HtmlEscapedString From 1573ec1f7874f62f2e2d7a7db7936f783ec7a64e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 6 Nov 2023 08:48:26 +0900 Subject: [PATCH 31/31] feat(jsx/streaming): remove implementation of `use()`. --- deno_dist/jsx/streaming.ts | 43 +------------- src/jsx/streaming.test.tsx | 113 ++++--------------------------------- src/jsx/streaming.ts | 43 +------------- 3 files changed, 18 insertions(+), 181 deletions(-) diff --git a/deno_dist/jsx/streaming.ts b/deno_dist/jsx/streaming.ts index dd5a13af1..f0a8d3d36 100644 --- a/deno_dist/jsx/streaming.ts +++ b/deno_dist/jsx/streaming.ts @@ -1,22 +1,15 @@ import type { HtmlEscapedString } from '../utils/html.ts' import type { FC, Child } from './index.ts' -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const useContexts: any[][] = [] - let suspenseCounter = 0 -let useCounter = 0 -let currentUseContext: number = 0 -let useIndex: number = -1 -async function childrenToString(useContext: number, children: Child): Promise { - setUseContext(useContext) +async function childrenToString(children: Child): Promise { try { return children.toString() } catch (e) { if (e instanceof Promise) { await e - return childrenToString(useContext, children) + return childrenToString(children) } else { throw e } @@ -35,7 +28,6 @@ export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => } let res - const useContext = createUseContext() try { res = children.toString() } catch (e) { @@ -54,7 +46,7 @@ export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => res.isEscaped = true res.promises = [ promise.then(async () => { - return ``, - ]) - - expect(replacementResult(`${chunks.join('')}`)).toEqual( - '

Hello

' - ) - }) - it('resolve(undefined)', async () => { - const Content = () => { - const content = use(Promise.resolve(undefined)) + const Content = async () => { + const content = await Promise.resolve(undefined) return

{content}

} @@ -145,8 +107,8 @@ d.replaceWith(c.content) }) it('resolve(null)', async () => { - const Content = () => { - const content = use(Promise.resolve(null)) + const Content = async () => { + const content = await Promise.resolve(null) return

{content}

} @@ -179,8 +141,8 @@ d.replaceWith(c.content) // This test should end successfully , but vitest catches the global unhandledRejection and makes an error, so it temporarily skips it.skip('reject()', async () => { - const Content = () => { - const content = use(Promise.reject()) + const Content = async () => { + const content = await Promise.reject() return

{content}

} @@ -213,9 +175,9 @@ d.replaceWith(c.content) const delayedContent2 = new Promise((resolve) => setTimeout(() => resolve(

World

), 10) ) - const Content = () => { - const content = use(delayedContent) - const content2 = use(delayedContent2) + const Content = async () => { + const content = await delayedContent + const content2 = await delayedContent2 return ( <> {content} @@ -253,64 +215,13 @@ d.replaceWith(c.content) ) }) - it('Nested calls to "use"', async () => { - const delayedContent = new Promise((resolve) => - setTimeout(() => resolve(

Hello

), 10) - ) - const delayedContent2 = new Promise((resolve) => - setTimeout(() => resolve(

paragraph

), 10) - ) - - const SubContent = () => { - const content = use(delayedContent2) - return <>{content} - } - const Content = () => { - const content = use(delayedContent) - return ( - <> - {content} - - - ) - } - - const stream = renderToReadableStream( - Loading...

}> - -
- ) - - const chunks = [] - const textDecoder = new TextDecoder() - for await (const chunk of stream as any) { - chunks.push(textDecoder.decode(chunk)) - } - - expect(chunks).toEqual([ - `

Loading...

`, - ``, - ]) - - expect(replacementResult(`${chunks.join('')}`)).toEqual( - '

Hello

paragraph

' - ) - }) - it('Complex fallback content', async () => { const delayedContent = new Promise((resolve) => setTimeout(() => resolve(

Hello

), 10) ) - const Content = () => { - const content = use(delayedContent) + const Content = async () => { + const content = await delayedContent return content } diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts index efec3271a..307dcf5c9 100644 --- a/src/jsx/streaming.ts +++ b/src/jsx/streaming.ts @@ -1,22 +1,15 @@ import type { HtmlEscapedString } from '../utils/html' import type { FC, Child } from './index' -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const useContexts: any[][] = [] - let suspenseCounter = 0 -let useCounter = 0 -let currentUseContext: number = 0 -let useIndex: number = -1 -async function childrenToString(useContext: number, children: Child): Promise { - setUseContext(useContext) +async function childrenToString(children: Child): Promise { try { return children.toString() } catch (e) { if (e instanceof Promise) { await e - return childrenToString(useContext, children) + return childrenToString(children) } else { throw e } @@ -35,7 +28,6 @@ export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => } let res - const useContext = createUseContext() try { res = children.toString() } catch (e) { @@ -54,7 +46,7 @@ export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => res.isEscaped = true res.promises = [ promise.then(async () => { - return `