From ff9e40dd17bf80d250e720b274065317472f988a Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Thu, 25 Jan 2024 14:34:09 -0800 Subject: [PATCH] Top level api (#2090) * feat: update template api for tags runtime * chore: remove remaining context references * feat: update template api for class runtime * fix: throw error if tracker is used after cleanup * fix: remove unused test --- .sizes.json | 44 ++-- .../marko/src/runtime/html/AsyncStream.js | 91 ++++++- packages/marko/src/runtime/renderable.js | 49 ++++ packages/runtime-tags/src/common/context.ts | 26 -- packages/runtime-tags/src/common/types.ts | 40 +-- packages/runtime-tags/src/dom/control-flow.ts | 2 +- packages/runtime-tags/src/dom/index.ts | 2 - packages/runtime-tags/src/dom/renderer.ts | 3 - packages/runtime-tags/src/dom/template.ts | 73 +++--- packages/runtime-tags/src/html/dynamic-tag.ts | 2 +- packages/runtime-tags/src/html/index.ts | 2 - packages/runtime-tags/src/html/template.ts | 228 +++++++++++------- packages/runtime-tags/src/html/writer.ts | 15 +- .../ambiguous/__snapshots__/csr.expected.md | 2 +- .../class/__snapshots__/csr.expected.md | 2 +- .../explicit/__snapshots__/csr.expected.md | 2 +- .../src/__tests__/main.test.ts | 79 ++---- .../__snapshots__/ssr-sanitized.expected.md | 2 +- .../error-async/__snapshots__/ssr.expected.md | 21 +- .../__snapshots__/ssr-sanitized.expected.md | 4 + .../error-sync/__snapshots__/ssr.expected.md | 13 +- .../__snapshots__/ssr-sanitized.expected.md | 5 - .../__snapshots__/ssr.expected.md | 46 ---- .../fixtures/placeholder-context/server.ts | 40 --- .../fixtures/placeholder-context/test.ts | 1 - .../src/__tests__/main.test.ts | 80 +++--- .../src/__tests__/utils/track-mutations.ts | 10 +- 27 files changed, 467 insertions(+), 417 deletions(-) delete mode 100644 packages/runtime-tags/src/common/context.ts delete mode 100644 packages/translator-tags/src/__tests__/fixtures/placeholder-context/__snapshots__/ssr-sanitized.expected.md delete mode 100644 packages/translator-tags/src/__tests__/fixtures/placeholder-context/__snapshots__/ssr.expected.md delete mode 100644 packages/translator-tags/src/__tests__/fixtures/placeholder-context/server.ts delete mode 100644 packages/translator-tags/src/__tests__/fixtures/placeholder-context/test.ts diff --git a/.sizes.json b/.sizes.json index b5fae0cc8c..7aec4d8b9d 100644 --- a/.sizes.json +++ b/.sizes.json @@ -7,9 +7,9 @@ { "name": "*", "total": { - "min": 13308, - "gzip": 5718, - "brotli": 5187 + "min": 13105, + "gzip": 5618, + "brotli": 5130 } }, { @@ -17,16 +17,16 @@ "user": { "min": 351, "gzip": 276, - "brotli": 233 + "brotli": 234 }, "runtime": { - "min": 4050, - "gzip": 1879, - "brotli": 1682 + "min": 4083, + "gzip": 1894, + "brotli": 1681 }, "total": { - "min": 4401, - "gzip": 2155, + "min": 4434, + "gzip": 2170, "brotli": 1915 } }, @@ -34,17 +34,17 @@ "name": "counter 💧", "user": { "min": 204, - "gzip": 177, + "gzip": 179, "brotli": 151 }, "runtime": { "min": 2612, - "gzip": 1354, + "gzip": 1350, "brotli": 1210 }, "total": { "min": 2816, - "gzip": 1531, + "gzip": 1529, "brotli": 1361 } }, @@ -52,35 +52,35 @@ "name": "comments", "user": { "min": 1216, - "gzip": 709, + "gzip": 701, "brotli": 636 }, "runtime": { - "min": 7478, - "gzip": 3469, - "brotli": 3121 + "min": 7506, + "gzip": 3457, + "brotli": 3116 }, "total": { - "min": 8694, - "gzip": 4178, - "brotli": 3757 + "min": 8722, + "gzip": 4158, + "brotli": 3752 } }, { "name": "comments 💧", "user": { "min": 988, - "gzip": 591, + "gzip": 587, "brotli": 544 }, "runtime": { "min": 7999, - "gzip": 3699, + "gzip": 3683, "brotli": 3342 }, "total": { "min": 8987, - "gzip": 4290, + "gzip": 4270, "brotli": 3886 } } diff --git a/packages/marko/src/runtime/html/AsyncStream.js b/packages/marko/src/runtime/html/AsyncStream.js index ce12c14369..93b4734087 100644 --- a/packages/marko/src/runtime/html/AsyncStream.js +++ b/packages/marko/src/runtime/html/AsyncStream.js @@ -39,6 +39,16 @@ function escapeEndingComment(text) { return text.replace(/(--!?)>/g, "$1>"); } +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function AsyncStream(global, writer, parentOut) { if (parentOut === null) { throw new Error("illegal state"); @@ -117,6 +127,77 @@ var proto = (AsyncStream.prototype = { ___host: typeof document === "object" && document, ___isOut: true, + [Symbol.asyncIterator]() { + if (this.___iterator) { + return this.___iterator; + } + + const originalWriter = this._state.writer; + let buffer = ""; + let iteratorNextFn; + + if (!originalWriter.stream) { + // Writing has finished completely so we can use a simple iterator + buffer = this.toString(); + iteratorNextFn = () => { + const value = buffer; + buffer = ""; + return { value, done: !value }; + }; + } else { + let done = false; + let pending = deferred(); + const stream = { + write(data) { + buffer += data; + }, + end() { + done = true; + pending.resolve({ + value: "", + done, + }); + }, + flush() { + pending.resolve({ + value: buffer, + done: false, + }); + buffer = ""; + pending = deferred(); + }, + }; + + this.on("error", pending.reject); + + const writer = new BufferedWriter(stream); + writer.stream = originalWriter.stream; + writer.stream.writer = writer; + writer.next = originalWriter.next; + writer.state = this._state; + writer.merge(originalWriter); + + this._state.stream = stream; + this._state.writer = writer; + + iteratorNextFn = async () => { + if (buffer || done) { + const value = buffer; + buffer = ""; + return { value, done }; + } + return pending.promise; + }; + } + + return (this.___iterator = { + next: iteratorNextFn, + [Symbol.asyncIterator]() { + return this; + }, + }); + }, + sync: function () { this._sync = true; }, @@ -637,20 +718,22 @@ var proto = (AsyncStream.prototype = { then: function (fn, fnErr) { var out = this; - var promise = new Promise(function (resolve, reject) { + return new Promise(function (resolve, reject) { out.on("error", reject); out.on("finish", function (result) { resolve(result); }); - }); - - return Promise.resolve(promise).then(fn, fnErr); + }).then(fn, fnErr); }, catch: function (fnErr) { return this.then(undefined, fnErr); }, + finally: function (fn) { + return this.then(undefined, undefined).finally(fn); + }, + c: function (componentDef, key, customEvents) { this.___assignedComponentDef = componentDef; this.___assignedKey = key; diff --git a/packages/marko/src/runtime/renderable.js b/packages/marko/src/runtime/renderable.js index 1076ed8597..7ca8951bfa 100644 --- a/packages/marko/src/runtime/renderable.js +++ b/packages/marko/src/runtime/renderable.js @@ -76,6 +76,55 @@ module.exports = function (target, renderer) { return out.___getResult(); }, + /** + * Renders a template to nodes and inserts them into the DOM relative + * to the provided reference based on the optional position parameter. + * + * Supported signatures: + * + * mount(data, reference) + * mount(data, reference, position) + * + * @param {Object} data The view model data for the template + * @param {Node} reference DOM node to insert the rendered node(s) relative to + * @param {string} [position] A string representing the position relative to the `reference`; must match (case-insensitively) one of the following strings: + * 'beforebegin': Before the targetElement itself. + * 'afterbegin': Just inside the targetElement, before its first child. + * 'beforeend': Just inside the targetElement, after its last child. + * 'afterend': After the targetElement itself. + * @return {TemplateInstance} Object with `update` and `dispose` methods + */ + mount: function (data, reference, position) { + const result = this.renderSync(data); + + switch (position) { + case "afterbegin": + result.prependTo(reference); + break; + case "afterend": + result.insertAfter(reference); + break; + case "beforebegin": + result.insertBefore(reference); + break; + default: + result.appendTo(reference); + break; + } + + const component = result.getComponent(); + + return { + update(input) { + component.input = input; + component.update(); + }, + destroy() { + component.destroy(); + }, + }; + }, + /** * Renders a template to either a stream (if the last * argument is a Stream instance) or diff --git a/packages/runtime-tags/src/common/context.ts b/packages/runtime-tags/src/common/context.ts deleted file mode 100644 index aab533da34..0000000000 --- a/packages/runtime-tags/src/common/context.ts +++ /dev/null @@ -1,26 +0,0 @@ -export let Context: Record | null = null; -let usesContext = false; - -export function pushContext(key: string, value: unknown) { - usesContext = true; - (Context = Object.create(Context))[key] = value; -} - -export function popContext() { - Context = Object.getPrototypeOf(Context!) as Record; -} - -export function getInContext(key: string) { - if ( - MARKO_DEBUG && - (!Context || !Object.prototype.hasOwnProperty.call(Context, key)) - ) { - throw new Error(`Unable to receive ${key} from current context`); - } - - return Context![key]; -} - -export function setContext(v: Record | null) { - usesContext && (Context = v); -} diff --git a/packages/runtime-tags/src/common/types.ts b/packages/runtime-tags/src/common/types.ts index 0fa3816a25..50e42d54fe 100644 --- a/packages/runtime-tags/src/common/types.ts +++ b/packages/runtime-tags/src/common/types.ts @@ -57,39 +57,25 @@ export const enum AccessorChars { } export type Accessor = string | number; - -export interface RenderResult { - insertBefore( - parent: ParentNode & Node, - reference: (ChildNode & Node) | null, - ): InsertResult; - toHTML(): Promise; - toPipableStream(): NodeJS.ReadableStream; - toReadableStream(): ReadableStream; -} - export type Input = Record; export type Context = Record; -export interface ITemplate { +export interface Template { _: unknown; - insertBefore( - parent: ParentNode & Node, - reference: (ChildNode & Node) | null, - input?: Input, - context?: Context, - ): InsertResult; - asHTML(input?: Input, context?: Context): Promise; - asReadableStream(input?: Input, context?: Context): ReadableStream; - asPipeableStream(input?: Input, context?: Context): NodeJS.ReadableStream; - writeTo( - writable: NodeJS.WritableStream, - input?: Input, - context?: Context, - ): void; + mount( + input: Input, + reference: ParentNode & Node, + position?: InsertPosition, + ): TemplateInstance; + render(input?: Input): RenderResult; } -export interface InsertResult { +export interface TemplateInstance { update(input: unknown): void; destroy(): void; } + +export type RenderResult = Promise & + AsyncIterable & { + toReadable(): ReadableStream; + }; diff --git a/packages/runtime-tags/src/dom/control-flow.ts b/packages/runtime-tags/src/dom/control-flow.ts index ba49747cf4..04d93405e0 100644 --- a/packages/runtime-tags/src/dom/control-flow.ts +++ b/packages/runtime-tags/src/dom/control-flow.ts @@ -12,7 +12,7 @@ import { type ValueSignal, renderBodyClosures, } from "./signals"; -import type { Template } from "./template"; +import type { ClientTemplate as Template } from "./template"; type LoopForEach = ( value: unknown[], diff --git a/packages/runtime-tags/src/dom/index.ts b/packages/runtime-tags/src/dom/index.ts index 80c55f6dbf..badf559c99 100644 --- a/packages/runtime-tags/src/dom/index.ts +++ b/packages/runtime-tags/src/dom/index.ts @@ -27,8 +27,6 @@ export { staticNodesFragment, dynamicFragment } from "./fragment"; export { init, register, registerSubscriber, scopeLookup } from "./resume"; -export { pushContext, popContext, getInContext } from "../common/context"; - export { queueSource, queueEffect, run, prepare, runEffects } from "./queue"; export { write, bindFunction, bindRenderer, createScope } from "./scope"; diff --git a/packages/runtime-tags/src/dom/renderer.ts b/packages/runtime-tags/src/dom/renderer.ts index c69f6ccea0..e0fb43b892 100644 --- a/packages/runtime-tags/src/dom/renderer.ts +++ b/packages/runtime-tags/src/dom/renderer.ts @@ -1,4 +1,3 @@ -import { setContext } from "../common/context"; import { type Accessor, AccessorChars, @@ -45,7 +44,6 @@ export function createScopeWithRenderer( context: ScopeContext, ownerScope?: Scope, ) { - setContext(context); const newScope = createScope(context as ScopeContext); newScope._ = renderer.___owner || ownerScope; newScope.___renderer = renderer as Renderer; @@ -55,7 +53,6 @@ export function createScopeWithRenderer( signal.___subscribe?.(newScope); } } - setContext(null); return newScope; } diff --git a/packages/runtime-tags/src/dom/template.ts b/packages/runtime-tags/src/dom/template.ts index a6624b1f6a..d7c8d52111 100644 --- a/packages/runtime-tags/src/dom/template.ts +++ b/packages/runtime-tags/src/dom/template.ts @@ -1,4 +1,10 @@ -import type { ITemplate, Input, InsertResult, Scope } from "../common/types"; +import type { + Template, + Input, + TemplateInstance, + Scope, + RenderResult, +} from "../common/types"; import { defaultFragment } from "./fragment"; import { prepare, runEffects, runSync } from "./queue"; import { type Renderer, initRenderer } from "./renderer"; @@ -6,21 +12,20 @@ import { register } from "./resume"; import { createScope, destroyScope } from "./scope"; export const createTemplate = (renderer: Renderer, templateId?: string) => - register(templateId!, new Template(renderer)); + register(templateId!, new ClientTemplate(renderer)); -export class Template implements ITemplate { +export class ClientTemplate implements Template { public _: Renderer; constructor(renderer: Renderer) { this._ = renderer; } - insertBefore( - parent: ParentNode & Node, - reference: (ChildNode & Node) | null, - input?: Input, - // context?: Context - ): InsertResult { + mount( + input: Input, + reference: ParentNode & Node, + position?: InsertPosition, + ): TemplateInstance { let scope!: Scope, dom!: Node; const attrs = this._.___attrs; const effects = prepare(() => { @@ -31,7 +36,31 @@ export class Template implements ITemplate { } }); - parent.insertBefore(dom, reference); + /* + + + + foo + + + + */ + + switch (position) { + case "afterbegin": + reference.insertBefore(dom, reference.firstChild); + break; + case "afterend": + reference.parentElement!.insertBefore(dom, reference.nextSibling); + break; + case "beforebegin": + reference.parentElement!.insertBefore(dom, reference); + break; + default: + reference.appendChild(dom); + break; + } + runEffects(effects); return { @@ -49,25 +78,9 @@ export class Template implements ITemplate { }; } - asHTML(): Promise { - throw unimplemented("asHTML"); - } - - asReadableStream(): ReadableStream { - throw unimplemented("asReadableStream"); - } - - asPipeableStream(): NodeJS.ReadableStream { - throw unimplemented("asPipeableStream"); - } - - writeTo(): void { - throw unimplemented("writeTo"); + render(): RenderResult { + throw new Error( + `render() is not implemented for the DOM compilation of a Marko template`, + ); } } - -function unimplemented(methodName: string) { - return new Error( - `${methodName}() is not implemented for the DOM compilation of a Marko template`, - ); -} diff --git a/packages/runtime-tags/src/html/dynamic-tag.ts b/packages/runtime-tags/src/html/dynamic-tag.ts index 54142fb689..8a1fc97f23 100644 --- a/packages/runtime-tags/src/html/dynamic-tag.ts +++ b/packages/runtime-tags/src/html/dynamic-tag.ts @@ -1,7 +1,7 @@ import type { Renderer } from "../common/types"; import { attrs } from "./attrs"; import { serializedScope } from "./serializer"; -import type { Template } from "./template"; +import type { ServerTemplate as Template } from "./template"; import { markResumeScopeStart, nextScopeId, diff --git a/packages/runtime-tags/src/html/index.ts b/packages/runtime-tags/src/html/index.ts index 4934d8dc06..160173a943 100644 --- a/packages/runtime-tags/src/html/index.ts +++ b/packages/runtime-tags/src/html/index.ts @@ -36,5 +36,3 @@ export { serializedScope, getRegistryInfo, } from "./serializer"; - -export { pushContext, popContext, getInContext } from "../common/context"; diff --git a/packages/runtime-tags/src/html/template.ts b/packages/runtime-tags/src/html/template.ts index e0a5ebc4b1..8b8da4e348 100644 --- a/packages/runtime-tags/src/html/template.ts +++ b/packages/runtime-tags/src/html/template.ts @@ -1,112 +1,168 @@ import type { Context, - ITemplate, + Template, Input, - InsertResult, + TemplateInstance, Renderer, + RenderResult, } from "../common/types"; import { register } from "./serializer"; import { type Writable, createRenderFn } from "./writer"; export const createTemplate = (renderer: Renderer, id = "") => - register(new Template(renderer), id); + register(new ServerTemplate(renderer), id); -export class Template implements ITemplate { - public _: Renderer; - public writeTo: ( - writable: Writable, - input?: Input, - context?: Context, - ) => void; +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(reason?: any): void; +} - constructor(renderer: Renderer) { - this._ = renderer; - this.writeTo = createRenderFn(renderer); +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function stringFromAsync( + iterable: AsyncIterable, +): Promise { + let str = ""; + for await (const part of iterable) { + str += part; } + return str; +} + +class ServerRenderResult implements RenderResult { + #iterable: AsyncIterableIterator; + #promise: Promise | undefined; - insertBefore(): InsertResult { - throw new Error("Not implemented"); + constructor(iterable: AsyncIterableIterator) { + this.#iterable = iterable; } - asHTML(input?: Input, context?: Context) { - return new Promise((resolve, reject) => { - let html = ""; - this.writeTo( - { - write(data: string) { - html += data; - return true; - }, - emit(name, error: Error) { - if (name === "error") { - reject(error); - } - }, - end() { - resolve(html); - return this; - }, - }, - input, - context, - ); - }); + [Symbol.asyncIterator]() { + return this.#iterable; } - asReadableStream(input?: Input, context?: Context) { + [Symbol.toStringTag] = "RenderResult"; + + then( + onfulfilled?: + | ((value: string) => TResult1 | PromiseLike) + | undefined + | null, + onrejected?: + | ((reason: any) => TResult2 | PromiseLike) + | undefined + | null, + ): Promise { + return (this.#promise ||= stringFromAsync(this.#iterable)).then( + onfulfilled, + onrejected, + ); + } + + catch( + onrejected?: + | ((reason: any) => TResult | PromiseLike) + | undefined + | null, + ): Promise { + return this.then(undefined, onrejected); + } + + finally(onfinally?: (() => void) | undefined | null): Promise { + return this.then(undefined, undefined).finally(onfinally); + } + + toReadable() { return new ReadableStream({ - start: (controller) => { - this.writeTo( - { - write(data: string) { - controller.enqueue(data); - return true; - }, - emit(name, err: Error) { - if (name === "error") { - controller.error(err); - } - }, - end() { - controller.close(); - }, - }, - input, - context, - ); + start: async (controller) => { + try { + for await (const chunk of this.#iterable) { + if (chunk) { + controller.enqueue(chunk); + } + } + controller.close(); + } catch (err) { + controller.error(err); + } }, }); } +} + +export class ServerTemplate implements Template { + #writeTo: (writable: Writable, input?: Input, context?: Context) => void; + + public _: Renderer; + + constructor(renderer: Renderer) { + this._ = renderer; + this.#writeTo = createRenderFn(renderer); + } + + mount(): TemplateInstance { + throw new Error( + `mount() is not implemented for the HTML compilation of a Marko template`, + ); + } + + render(templateInput: Input = {}): RenderResult { + const { $global, ...input } = templateInput; + let buffer = ""; + let done = false; + let pending = deferred>(); - asPipeableStream(input?: Input, context?: Context) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const template = this; - // Trying to hide the `require` from bundlers/tools - // This should throw outside of Node.js - const dynamicRequire = typeof require === "function" ? require : undefined; - const stream = dynamicRequire!( - "node:stream", - ) as typeof import("node:stream"); - const readable = new stream.Readable({ - read() { - template.writeTo( - { - write(data: string) { - readable.push(data); - return true; - }, - emit(name: string, data: unknown) { - readable.emit(name, data); - }, - end() { - readable.push(null); - }, - }, - input, - context, - ); + this.#writeTo( + { + write(data: string) { + buffer += data; + }, + flush() { + pending.resolve({ + value: buffer, + done: false, + }); + buffer = ""; + pending = deferred(); + }, + emit(name, error: Error) { + if (name === "error") { + pending.reject(error); + } + }, + end() { + done = true; + pending.resolve({ + value: "", + done, + }); + }, + }, + input, + $global as Context, + ); + + return new ServerRenderResult({ + [Symbol.asyncIterator]() { + return this; + }, + async next() { + if (buffer || done) { + const value = buffer; + buffer = ""; + return { value, done }; + } + return pending.promise; }, }); - return readable; } } diff --git a/packages/runtime-tags/src/html/writer.ts b/packages/runtime-tags/src/html/writer.ts index 890a18cff5..a23096c4db 100644 --- a/packages/runtime-tags/src/html/writer.ts +++ b/packages/runtime-tags/src/html/writer.ts @@ -1,9 +1,3 @@ -import { - Context, - popContext, - pushContext, - setContext, -} from "../common/context"; import { type Accessor, type Renderer, ResumeSymbols } from "../common/types"; import reorderRuntime from "./reorder-runtime"; import { Serializer } from "./serializer"; @@ -55,7 +49,7 @@ export function createRenderFn(renderer: Renderer) { return ( stream: Writable, input: Input = {}, - context: Record = {}, + global: Record = {}, streamState: Partial = {}, ) => { let remainingChildren = 1; @@ -73,9 +67,8 @@ export function createRenderFn(renderer: Renderer) { }; $_buffer = createInitialBuffer(stream); - streamState.global = context; + streamState.global = global; $_streamData = createStreamState(streamState); - pushContext("$", context); $_buffer.onReject = reject; $_buffer.onAsync = async; @@ -89,7 +82,6 @@ export function createRenderFn(renderer: Renderer) { } finally { $_buffer = originalBuffer; $_streamData = originalStreamState; - popContext(); } }; } @@ -228,7 +220,6 @@ export async function fork( ) { const originalBuffer = $_buffer!; const originalStreamState = $_streamData!; - const originalContext = Context; scheduleFlush(); $_buffer!.pending = true; @@ -243,7 +234,6 @@ export async function fork( } finally { $_buffer = originalBuffer; $_streamData = originalStreamState; - setContext(originalContext); scheduleFlush(); } renderResult(result); @@ -373,7 +363,6 @@ function clearBuffer(buffer: Buffer) { function clearScope() { $_buffer = $_streamData = null; - setContext(null); } /* Async */ diff --git a/packages/translator-interop/src/__tests__/fixtures/ambiguous/__snapshots__/csr.expected.md b/packages/translator-interop/src/__tests__/fixtures/ambiguous/__snapshots__/csr.expected.md index 3f85f291ec..ab7e824dbe 100644 --- a/packages/translator-interop/src/__tests__/fixtures/ambiguous/__snapshots__/csr.expected.md +++ b/packages/translator-interop/src/__tests__/fixtures/ambiguous/__snapshots__/csr.expected.md @@ -1,4 +1,4 @@ -# Render undefined +# Render {} ```html

Hello world diff --git a/packages/translator-interop/src/__tests__/fixtures/class/__snapshots__/csr.expected.md b/packages/translator-interop/src/__tests__/fixtures/class/__snapshots__/csr.expected.md index f04dd69f54..7bb11bb162 100644 --- a/packages/translator-interop/src/__tests__/fixtures/class/__snapshots__/csr.expected.md +++ b/packages/translator-interop/src/__tests__/fixtures/class/__snapshots__/csr.expected.md @@ -1,4 +1,4 @@ -# Render undefined +# Render {} ```html