From d2e723b5dff624536b2452a48365b6fc6859b3ad Mon Sep 17 00:00:00 2001 From: Michael Rawlings Date: Fri, 5 Nov 2021 15:55:27 -0700 Subject: [PATCH] feat: pull in custom serializer --- .sizes.json | 6 +- .../basic-counter/ssr-hydrate.expected.md | 12 +- packages/runtime/src/dom/hydrate.ts | 25 +- packages/runtime/src/dom/scope.ts | 8 +- packages/runtime/src/html/index.ts | 2 + packages/runtime/src/html/serializer.ts | 428 ++++++++++++++++++ packages/runtime/src/html/writer.ts | 18 +- 7 files changed, 473 insertions(+), 26 deletions(-) create mode 100644 packages/runtime/src/html/serializer.ts diff --git a/.sizes.json b/.sizes.json index 182bea5f8..d0ecb94b5 100644 --- a/.sizes.json +++ b/.sizes.json @@ -6,9 +6,9 @@ { "name": "*", "individual": { - "min": 11965, - "gzip": 5131, - "brotli": 4621 + "min": 11962, + "gzip": 5140, + "brotli": 4628 } } ] diff --git a/packages/runtime/src/__tests__/__snapshots__/runtime/basic-counter/ssr-hydrate.expected.md b/packages/runtime/src/__tests__/__snapshots__/runtime/basic-counter/ssr-hydrate.expected.md index a1080a1ef..9100fdd82 100644 --- a/packages/runtime/src/__tests__/__snapshots__/runtime/basic-counter/ssr-hydrate.expected.md +++ b/packages/runtime/src/__tests__/__snapshots__/runtime/basic-counter/ssr-hydrate.expected.md @@ -1,5 +1,5 @@ # Write - + # Render "End" @@ -14,7 +14,7 @@ 0 @@ -49,7 +49,7 @@ inserted #document/html1/body1/script2/#text0 0 @@ -76,7 +76,7 @@ container.querySelector("button").click(); 1 @@ -103,7 +103,7 @@ container.querySelector("button").click(); 2 @@ -130,7 +130,7 @@ container.querySelector("button").click(); 3 diff --git a/packages/runtime/src/dom/hydrate.ts b/packages/runtime/src/dom/hydrate.ts index ce1be27bf..f35d72f58 100644 --- a/packages/runtime/src/dom/hydrate.ts +++ b/packages/runtime/src/dom/hydrate.ts @@ -1,5 +1,5 @@ import { Scope, ScopeOffsets, HydrateSymbols } from "../common/types"; -import { runWithScope } from "./scope"; +import { bind, runWithScope } from "./scope"; type HydrateFn = () => void; @@ -23,9 +23,14 @@ export function init(runtimeId = "M" /* [a-zA-Z0-9]+ */) { let currentScope: Scope; let currentOffset: number; let currentNode: Node; - const scopeLookup: Map = new Map(); + const scopeLookup: Record = {}; const stack: Array = []; const fakeArray = { push: hydrate }; + const bindFunction = (fnId: string, offset: number, scopeId: string) => { + const fn = fnsById[fnId]; + const scope = scopeLookup[scopeId]; + return bind(fn, offset, scope); + }; Object.defineProperty(window, hydrateVar, { get() { @@ -40,13 +45,15 @@ export function init(runtimeId = "M" /* [a-zA-Z0-9]+ */) { } function hydrate( - scopes: Record, + scopesFn: (b, s, ...rest: unknown[]) => Record, calls: Array ) { if (doc.readyState !== "loading") { walker.currentNode = doc; } + const scopes = scopesFn(bindFunction, scopeLookup); + /** * Loop over all the new hydration scopes and see if a previous walk * had to create a dummy scope to store Nodes of interest. @@ -54,13 +61,13 @@ export function init(runtimeId = "M" /* [a-zA-Z0-9]+ */) { */ for (const scopeId in scopes) { const scope = scopes[scopeId]; - const storedScope = scopeLookup.get(scope[ScopeOffsets.ID]); + const storedScope = scopeLookup[scopeId]; if (storedScope !== scope) { if (storedScope) { Object.assign(scope, storedScope); } - scopeLookup.set(scope[ScopeOffsets.ID], scope); + scopeLookup[scopeId] = scope; if (currentScope === storedScope) { currentScope = scope; } @@ -91,10 +98,10 @@ export function init(runtimeId = "M" /* [a-zA-Z0-9]+ */) { if (currentScope) { stack.push(currentScope[ScopeOffsets.ID] as string, currentOffset); } - currentScope = scopeLookup.get(data)!; + currentScope = scopeLookup[data]!; currentOffset = 0; if (!currentScope) { - scopeLookup.set(data, (currentScope = [data] as unknown as Scope)); + scopeLookup[data] = currentScope = [data] as unknown as Scope; } currentScope[ScopeOffsets.START_NODE] = currentNode; } else if (token === HydrateSymbols.SCOPE_END) { @@ -106,7 +113,7 @@ export function init(runtimeId = "M" /* [a-zA-Z0-9]+ */) { } currentScope[ScopeOffsets.END_NODE] = currentNode; currentOffset = stack.pop() as number; - currentScope = scopeLookup.get(stack.pop() as string)!; + currentScope = scopeLookup[stack.pop() as string]!; // eslint-disable-next-line no-constant-condition } else if ("MARKO_DEBUG") { throw new Error("MALFORMED MARKER: " + nodeValue); @@ -118,7 +125,7 @@ export function init(runtimeId = "M" /* [a-zA-Z0-9]+ */) { runWithScope( fnsById[calls[i]]!, calls[i + 1] as number, - scopeLookup.get(calls[i + 2] as string) + scopeLookup[calls[i + 2]] ); } } diff --git a/packages/runtime/src/dom/scope.ts b/packages/runtime/src/dom/scope.ts index da7a39c4d..b03fcb477 100644 --- a/packages/runtime/src/dom/scope.ts +++ b/packages/runtime/src/dom/scope.ts @@ -68,9 +68,11 @@ export function getOwnerScope(ownerLevel = 1) { return scope; } -export function bind(fn: (...args: unknown[]) => unknown) { - const boundScope = currentScope; - const boundOffset = currentOffset; +export function bind( + fn: (...args: unknown[]) => unknown, + boundOffset = currentOffset, + boundScope = currentScope +) { return fn.length ? (...args: unknown[]) => runWithScope(fn, boundOffset, boundScope, args) : () => runWithScope(fn, boundOffset, boundScope); diff --git a/packages/runtime/src/html/index.ts b/packages/runtime/src/html/index.ts index 16cc0d4be..9b0d99ef4 100644 --- a/packages/runtime/src/html/index.ts +++ b/packages/runtime/src/html/index.ts @@ -21,4 +21,6 @@ export { writeScope, } from "./writer"; +export { register } from "./serializer"; + export { pushContext, popContext, getInContext } from "../common/context"; diff --git a/packages/runtime/src/html/serializer.ts b/packages/runtime/src/html/serializer.ts new file mode 100644 index 000000000..275af0626 --- /dev/null +++ b/packages/runtime/src/html/serializer.ts @@ -0,0 +1,428 @@ +/* eslint "@typescript-eslint/ban-types": ["error", { "types": { "object": false }, "extendDefaults": true }] */ + +const { hasOwnProperty } = Object.prototype; +const PARAM_BIND = "b"; +const PARAM_SCOPE = "s"; +const REF_START_CHARS = "hjkmoquxzABCDEFGHIJKLNPQRTUVWXYZ$_"; // Avoids chars that could evaluate to a reserved word. +const REF_START_CHARS_LEN = REF_START_CHARS.length; +const REF_CHARS = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$_"; +const REF_CHARS_LEN = REF_CHARS.length; +const SYMBOL_FN_ID = Symbol("FN_ID"); +const SYMBOL_SCOPE_ID = Symbol("SCOPE_ID"); +const SYMBOL_OFFSET = Symbol("OFFSET"); + +type SerializableFn = ((...args: unknown[]) => unknown) & { + [SYMBOL_FN_ID]: string; + [SYMBOL_SCOPE_ID]: string; + [SYMBOL_OFFSET]: number; +}; + +export function register( + fn: (...args: unknown[]) => unknown, + fnId: string, + scopeId: string, + offset: number +): SerializableFn { + (fn as SerializableFn)[SYMBOL_FN_ID] = fnId; + (fn as SerializableFn)[SYMBOL_SCOPE_ID] = scopeId; + (fn as SerializableFn)[SYMBOL_OFFSET] = offset; + return fn as SerializableFn; +} + +export function stringify(root: unknown) { + return new Serializer().stringify(root); +} +export class Serializer { + // TODO: hoist these back out? + STACK: object[] = []; + BUFFER: string[] = [""]; + ASSIGNMENTS: Map = new Map(); + INDEX_OR_REF: WeakMap = new WeakMap(); + REF_COUNT = 0; + // These stay + PARENTS: WeakMap = new WeakMap(); + KEYS: WeakMap = new WeakMap(); + + constructor() { + this.BUFFER.pop(); + } + + stringify(root: unknown) { + if (this.writeProp(root, "", undefined)) { + const { BUFFER, REF_COUNT, ASSIGNMENTS, INDEX_OR_REF } = this; + + let result = BUFFER[0]; + + for (let i = 1, len = BUFFER.length; i < len; i++) { + result += BUFFER[i]; + } + + if (REF_COUNT) { + if (ASSIGNMENTS.size) { + let ref = INDEX_OR_REF.get(root as object); + + if (typeof ref === "number") { + ref = toRefParam(this.REF_COUNT++); + result = ref + "=" + result; + } + + for (const [assignmentRef, assignments] of ASSIGNMENTS) { + result += "," + assignments + assignmentRef; + } + + result += "," + ref; + this.ASSIGNMENTS = new Map(); + } + + result = + "(" + + PARAM_BIND + + "," + + PARAM_SCOPE + + "," + + this.refParamsString() + + ")=>(" + + result + + ")"; + } else if (root && (root as object).constructor === Object) { + result = "(" + PARAM_BIND + "," + PARAM_SCOPE + ")=>(" + result + ")"; + } + + BUFFER.length = 0; + this.INDEX_OR_REF = new WeakMap(); + + return result; + } + + return "void 0"; + } + + writeProp( + cur: unknown, + accessor: string | number, + parent: object | undefined + ): boolean { + const { BUFFER } = this; + switch (typeof cur) { + case "string": + BUFFER.push(quote(cur, 0)); + break; + + case "number": + BUFFER.push(cur + ""); + break; + + case "boolean": + BUFFER.push(cur ? "!0" : "!1"); + break; + + case "object": + if (cur === null) { + BUFFER.push("null"); + } else { + const ref = this.getRef(cur, accessor, parent); + + switch (ref) { + case true: + return false; + case false: + switch (cur.constructor) { + case Object: + this.writeObject(cur as Record); + break; + + case Array: + this.writeArray(cur as unknown[]); + break; + + case Date: + BUFFER.push( + 'new Date("' + (cur as Date).toISOString() + '")' + ); + break; + + case RegExp: + BUFFER.push(cur + ""); + break; + + case Map: + BUFFER.push("new Map("); + this.writeArray( + Array.from(cur as Map | Set) + ); + BUFFER.push(")"); + break; + + case Set: + BUFFER.push("new Set("); + this.writeArray( + Array.from(cur as Map | Set) + ); + BUFFER.push(")"); + break; + + case undefined: + BUFFER.push("Object.assign(Object.create(null),"); + this.writeObject(cur as Record); + BUFFER.push("))"); + break; + + default: + return false; + } + break; + + default: + BUFFER.push(ref); + break; + } + } + break; + case "function": { + return this.writeFunction(cur as SerializableFn); + } + + default: + return false; + } + + return true; + } + + writeFunction(fn: SerializableFn) { + const { + [SYMBOL_FN_ID]: fnId, + [SYMBOL_SCOPE_ID]: scopeId, + [SYMBOL_OFFSET]: offset, + } = fn; + if (fnId && scopeId && offset != null) { + // ASSERT: fnId and scopeId don't need `quote` escaping + this.BUFFER.push(`${PARAM_BIND}("${fnId}",${offset},"${scopeId}")`); + return true; + } + return false; + } + + writeObject(obj: Record) { + const { STACK, BUFFER } = this; + + let sep = "{"; + STACK.push(obj); + + for (const key in obj) { + if (hasOwnProperty.call(obj, key)) { + const val = obj[key]; + const escapedKey = toObjectKey(key); + BUFFER.push(sep + escapedKey + ":"); + if (this.writeProp(val, escapedKey, obj)) { + sep = ","; + } else { + BUFFER.pop(); + } + } + } + + if (sep === "{") { + BUFFER.push("{}"); + } else { + BUFFER.push("}"); + } + + STACK.pop(); + } + + writeArray(arr: unknown[]) { + const { STACK, BUFFER } = this; + + BUFFER.push("["); + STACK.push(arr); + + this.writeProp(arr[0], 0, arr); + + for (let i = 1, len = arr.length; i < len; i++) { + BUFFER.push(","); + this.writeProp(arr[i], i, arr); + } + + STACK.pop(); + BUFFER.push("]"); + } + + getRef(cur: object, accessor: string | number, parent: object | undefined) { + const { STACK, BUFFER, INDEX_OR_REF, ASSIGNMENTS, PARENTS, KEYS } = this; + + let ref = INDEX_OR_REF.get(cur); + + if (ref === undefined) { + INDEX_OR_REF.set(cur, BUFFER.length); + + let knownParent = PARENTS.get(cur); + + if (knownParent === undefined) { + PARENTS.set(cur, parent!); + KEYS.set(cur, accessor); + return false; + } else { + let ref = ""; + while (knownParent) { + ref = toPropertyAccess(KEYS.get(cur)!) + ref; + knownParent = PARENTS.get((cur = knownParent)); + } + return PARAM_SCOPE + ref; + } + } + + if (typeof ref === "number") { + ref = this.insertAndGetRef(cur, ref); + } + + if (STACK.includes(cur)) { + const parent = STACK[STACK.length - 1]; + let parentRef = INDEX_OR_REF.get(parent) as string | number; + + if (typeof parentRef === "number") { + parentRef = this.insertAndGetRef(parent, parentRef); + } + + ASSIGNMENTS.set( + ref, + (ASSIGNMENTS.get(ref) || "") + toAssignment(parentRef, accessor) + "=" + ); + return true; + } + + return ref; + } + + insertAndGetRef(obj: object, pos: number) { + const ref = toRefParam(this.REF_COUNT++); + this.INDEX_OR_REF.set(obj, ref); + if (pos) { + this.BUFFER[pos - 1] += ref + "="; + } else { + this.BUFFER[pos] = ref + "=" + this.BUFFER[pos]; + } + + return ref; + } + + refParamsString() { + let result = REF_START_CHARS[0]; + + for (let i = 1; i < this.REF_COUNT; i++) { + result += "," + toRefParam(i); + } + + this.REF_COUNT = 0; + return result; + } +} + +function toObjectKey(name: string) { + const invalidIdentifierPos = getInvalidIdentifierPos(name); + return invalidIdentifierPos === -1 ? name : quote(name, invalidIdentifierPos); +} + +function toAssignment(parent: string, key: string | number) { + return parent + toPropertyAccess(key); +} + +function toPropertyAccess(key: string | number) { + return typeof key === "number" || key[0] === '"' + ? "[" + key + "]" + : "." + key; +} + +function getInvalidIdentifierPos(name: string) { + let char = name[0]; + if ( + !( + (char >= "a" && char <= "z") || + (char >= "A" && char <= "Z") || + char === "$" || + char === "_" + ) + ) { + return 0; + } + + for (let i = 1, len = name.length; i < len; i++) { + char = name[i]; + if ( + !( + (char >= "a" && char <= "z") || + (char >= "A" && char <= "Z") || + (char >= "0" && char <= "9") || + char === "$" || + char === "_" + ) + ) { + return i; + } + } + + return -1; +} + +// Creates a JavaScript double quoted string and escapes all characters not listed as DoubleStringCharacters on +// Also includes "<" to escape "" and "\" to avoid invalid escapes in the output. +// http://www.ecma-international.org/ecma-262/5.1/#sec-7.8.4 +function quote(str: string, startPos: number): string { + let result = ""; + let lastPos = 0; + + for (let i = startPos, len = str.length; i < len; i++) { + let replacement: string; + switch (str[i]) { + case '"': + replacement = '\\"'; + break; + case "\\": + replacement = "\\\\"; + break; + case "<": + replacement = "\\x3C"; + break; + case "\n": + replacement = "\\n"; + break; + case "\r": + replacement = "\\r"; + break; + case "\u2028": + replacement = "\\u2028"; + break; + case "\u2029": + replacement = "\\u2029"; + break; + default: + continue; + } + + result += str.slice(lastPos, i) + replacement; + lastPos = i + 1; + } + + if (lastPos === startPos) { + result = str; + } else { + result += str.slice(lastPos); + } + + return '"' + result + '"'; +} + +function toRefParam(index: number) { + let mod = index % REF_START_CHARS_LEN; + let ref = REF_START_CHARS[mod]; + index = (index - mod) / REF_START_CHARS_LEN; + + while (index > 0) { + mod = index % REF_CHARS_LEN; + ref += REF_CHARS[mod]; + index = (index - mod) / REF_CHARS_LEN; + } + + return ref; +} diff --git a/packages/runtime/src/html/writer.ts b/packages/runtime/src/html/writer.ts index 19719189c..18e995e2c 100644 --- a/packages/runtime/src/html/writer.ts +++ b/packages/runtime/src/html/writer.ts @@ -2,6 +2,7 @@ import type { Writable } from "stream"; import { Context, setContext } from "../common/context"; import { Renderer, Scope, ScopeOffsets, HydrateSymbols } from "../common/types"; import reorderRuntime from "./reorder-runtime"; +import { Serializer } from "./serializer"; const runtimeId = "M"; const reorderRuntimeString = String(reorderRuntime).replace( @@ -18,7 +19,7 @@ let $_promises: Array & { isPlaceholder?: true }> | null = const uids: WeakMap = new WeakMap(); const runtimeFlushed: WeakSet = new WeakSet(); -const hydrateFlushed: WeakSet = new WeakSet(); +const streamSerializers: WeakMap = new WeakMap(); export function nextId() { const id = uids.get($_stream!)! + 1 || 0; @@ -286,11 +287,18 @@ export function markReplaceEnd(id: number) { function flushToStream() { if ($_buffer!.calls || $_buffer!.scopes) { + let isFirstFlush; + let serializer = streamSerializers.get($_stream!); + if ((isFirstFlush = !serializer)) { + streamSerializers.set($_stream!, (serializer = new Serializer())); + } $_buffer!.content += ``; + isFirstFlush + ? `(${runtimeId + HydrateSymbols.VAR_HYDRATE}=[])` + : runtimeId + HydrateSymbols.VAR_HYDRATE + }.push(${serializer.stringify($_buffer!.scopes)},[${ + $_buffer!.calls + }])`; } $_stream!.write($_buffer!.content); if ($_stream!.flush) {