From 079cd04a2bfd80e78f04b38fbb532fb3e3cbb0f5 Mon Sep 17 00:00:00 2001 From: weareoutman Date: Wed, 23 Oct 2024 16:20:57 +0800 Subject: [PATCH] feat(): support slots in template --- .../CustomTemplates/expandCustomTemplate.ts | 44 +++-- .../replaceSlotWithSlottedBricks.spec.ts | 151 ++++++++++++++++++ .../replaceSlotWithSlottedBricks.ts | 75 +++++++++ .../CustomTemplates/setupTemplateProxy.ts | 12 +- .../setupUseBrickInTemplate.ts | 31 +++- .../runtime/src/internal/Renderer.spec.ts | 148 +++++++++++++++++ packages/runtime/src/internal/Renderer.ts | 4 +- .../runtime/src/internal/data/DataStore.ts | 1 - packages/runtime/src/internal/interfaces.ts | 1 + 9 files changed, 445 insertions(+), 22 deletions(-) create mode 100644 packages/runtime/src/internal/CustomTemplates/replaceSlotWithSlottedBricks.spec.ts create mode 100644 packages/runtime/src/internal/CustomTemplates/replaceSlotWithSlottedBricks.ts diff --git a/packages/runtime/src/internal/CustomTemplates/expandCustomTemplate.ts b/packages/runtime/src/internal/CustomTemplates/expandCustomTemplate.ts index 211acf606d..5ed0c4fd02 100644 --- a/packages/runtime/src/internal/CustomTemplates/expandCustomTemplate.ts +++ b/packages/runtime/src/internal/CustomTemplates/expandCustomTemplate.ts @@ -1,6 +1,7 @@ import type { BrickConf, BrickConfInTemplate, + SlotConfOfBricks, SlotsConfInTemplate, SlotsConfOfBricks, UseSingleBrickConf, @@ -8,7 +9,6 @@ import type { import { uniqueId } from "lodash"; import { customTemplates } from "../../CustomTemplates.js"; import { DataStore } from "../data/DataStore.js"; -import { RuntimeBrickConfWithTplSymbols } from "./constants.js"; import { setupTemplateProxy } from "./setupTemplateProxy.js"; import type { AsyncPropertyEntry, @@ -20,6 +20,7 @@ import { setupUseBrickInTemplate } from "./setupUseBrickInTemplate.js"; import { childrenToSlots } from "../Renderer.js"; import { hooks } from "../Runtime.js"; import type { RendererContext } from "../RendererContext.js"; +import { replaceSlotWithSlottedBricks } from "./replaceSlotWithSlottedBricks.js"; export function expandCustomTemplate( tplTagName: string, @@ -115,12 +116,15 @@ export function expandCustomTemplate( | undefined, tplStateStoreId, hostBrick: hostBrick as TemplateHostBrick, + usedSlots: new Set(), }; newBrickConf.slots = { "": { type: "bricks", - bricks: bricks.map((item) => expandBrickInTemplate(item, hostContext)), + bricks: bricks.flatMap((item) => + expandBrickInTemplate(item, hostContext) + ), }, }; @@ -128,32 +132,47 @@ export function expandCustomTemplate( } function expandBrickInTemplate( - brickConfInTemplate: BrickConfInTemplate, - hostContext: TemplateHostContext -): RuntimeBrickConfWithTplSymbols { + brickConfInTemplate: BrickConf, + hostContext: TemplateHostContext, + markSlotted?: () => void +): BrickConf | BrickConf[] { // Ignore `if: null` to make `looseCheckIf` working. if (brickConfInTemplate.if === null) { delete brickConfInTemplate.if; } + + if (brickConfInTemplate.brick === "slot") { + markSlotted?.(); + return replaceSlotWithSlottedBricks( + brickConfInTemplate, + hostContext, + expandBrickInTemplate + ); + } + const { properties, slots: slotsInTemplate, children: childrenInTemplate, ...restBrickConfInTemplate - } = brickConfInTemplate; + } = brickConfInTemplate as BrickConfInTemplate; const transpiledSlots = childrenToSlots( childrenInTemplate, slotsInTemplate ) as SlotsConfInTemplate | undefined; - const slots: SlotsConfOfBricks = Object.fromEntries( + let slotted = false; + const markChild = () => { + slotted = true; + }; + const slots = Object.fromEntries( Object.entries(transpiledSlots ?? {}).map(([slotName, slotConf]) => [ slotName, { type: "bricks", - bricks: (slotConf.bricks ?? []).map((item) => - expandBrickInTemplate(item, hostContext) + bricks: (slotConf.bricks ?? []).flatMap((item) => + expandBrickInTemplate(item, hostContext, markChild) ), }, ]) @@ -163,6 +182,11 @@ function expandBrickInTemplate( ...restBrickConfInTemplate, properties: setupUseBrickInTemplate(properties, hostContext), slots, - ...setupTemplateProxy(hostContext, restBrickConfInTemplate.ref, slots), + ...setupTemplateProxy( + hostContext, + restBrickConfInTemplate.ref, + slots, + slotted + ), }; } diff --git a/packages/runtime/src/internal/CustomTemplates/replaceSlotWithSlottedBricks.spec.ts b/packages/runtime/src/internal/CustomTemplates/replaceSlotWithSlottedBricks.spec.ts new file mode 100644 index 0000000000..4b6ef5828e --- /dev/null +++ b/packages/runtime/src/internal/CustomTemplates/replaceSlotWithSlottedBricks.spec.ts @@ -0,0 +1,151 @@ +import type { BrickConf } from "@next-core/types"; +import { identity } from "lodash"; +import { replaceSlotWithSlottedBricks } from "./replaceSlotWithSlottedBricks.js"; +import type { TemplateHostContext } from "../interfaces.js"; + +describe("replaceSlotWithSlottedBricks", () => { + let mockHostContext: TemplateHostContext; + + beforeEach(() => { + mockHostContext = { + hostBrick: { + type: "tpl-test", + runtimeContext: {}, + }, + usedSlots: new Set(), + } as unknown as TemplateHostContext; + }); + + it("should throw an error if 'if' is used in a slot", () => { + const brickConf = { brick: "slot", if: false }; + expect(() => + replaceSlotWithSlottedBricks(brickConf, mockHostContext, identity) + ).toThrow( + `Can not use "if" in a slot currently, check your template "tpl-test"` + ); + const brickConf2 = { brick: "slot", if: "<% true %>" }; + expect(() => + replaceSlotWithSlottedBricks(brickConf2, mockHostContext, identity) + ).toThrow( + `Can not use "if" in a slot currently, check your template "tpl-test"` + ); + }); + + it("should throw an error if slot name is an expression", () => { + const brickConf = { + brick: "slot", + properties: { name: "<% 'abc' %>" }, + } as BrickConf; + expect(() => + replaceSlotWithSlottedBricks(brickConf, mockHostContext, identity) + ).toThrow( + `Can not use an expression as slot name "<% 'abc' %>" currently, check your template "tpl-test"` + ); + }); + + it("should throw an error if slot name is repeated", () => { + const brickConf = { + brick: "div", + properties: { name: "repeated-slot" }, + } as BrickConf; + mockHostContext.usedSlots.add("repeated-slot"); + expect(() => + replaceSlotWithSlottedBricks(brickConf, mockHostContext, identity) + ).toThrow( + `Can not have multiple slots with the same name "repeated-slot", check your template "tpl-test"` + ); + }); + + it("should return external bricks if available", () => { + const brickConf = { + brick: "slot", + properties: { name: "slot1" }, + } as BrickConf; + mockHostContext.externalSlots = { + slot1: { bricks: [{ brick: "h1" }, { brick: "h2" }] }, + }; + const result = replaceSlotWithSlottedBricks( + brickConf, + mockHostContext, + identity + ); + expect(result).toEqual([{ brick: "h1" }, { brick: "h2" }]); + }); + + it("should return expanded default slots if no external bricks", () => { + const brickConf = { + brick: "slot", + properties: { name: "slot1" }, + slots: { + "": { + bricks: [ + { + brick: "p", + }, + ], + }, + oops: { + bricks: [ + { + brick: "hr", + }, + ], + }, + }, + } as BrickConf; + const expandMock = jest.fn().mockImplementation((item) => [item]); + const result = replaceSlotWithSlottedBricks( + brickConf, + mockHostContext, + expandMock + ); + expect(result).toEqual([{ brick: "p" }]); + expect(expandMock).toHaveBeenCalledTimes(1); + }); + + it("no default bricks nor external bricks", () => { + const brickConf = { + brick: "slot", + properties: { name: "slot1" }, + } as BrickConf; + const result = replaceSlotWithSlottedBricks( + brickConf, + mockHostContext, + identity + ); + expect(result).toEqual([]); + }); + + it("should handle external bricks with forEach", () => { + const brickConf = { brick: "slot" }; + mockHostContext.externalSlots = { + "": { bricks: [{ brick: "h1" }, { brick: "h2" }] }, + }; + mockHostContext.hostBrick.runtimeContext = { + forEachItem: "item", + forEachIndex: 0, + forEachSize: 2, + } as any; + const result = replaceSlotWithSlottedBricks( + brickConf, + mockHostContext, + identity + ); + expect(result).toEqual([ + { + brick: "h1", + slots: {}, + [Symbol.for("tpl.externalForEachItem")]: "item", + [Symbol.for("tpl.externalForEachIndex")]: 0, + [Symbol.for("tpl.externalForEachSize")]: 2, + }, + { + brick: "h2", + slots: {}, + [Symbol.for("tpl.externalForEachItem")]: "item", + [Symbol.for("tpl.externalForEachIndex")]: 0, + [Symbol.for("tpl.externalForEachSize")]: 2, + }, + ]); + }); +}); diff --git a/packages/runtime/src/internal/CustomTemplates/replaceSlotWithSlottedBricks.ts b/packages/runtime/src/internal/CustomTemplates/replaceSlotWithSlottedBricks.ts new file mode 100644 index 0000000000..06fa0bfe3d --- /dev/null +++ b/packages/runtime/src/internal/CustomTemplates/replaceSlotWithSlottedBricks.ts @@ -0,0 +1,75 @@ +import type { + BrickConf, + SlotsConfInTemplate, + UseSingleBrickConf, +} from "@next-core/types"; +import { hasOwnProperty } from "@next-core/utils/general"; +import { isEvaluable } from "@next-core/cook"; +import type { TemplateHostContext } from "../interfaces.js"; +import { setupTemplateExternalBricksWithForEach } from "./setupTemplateProxy.js"; +import { childrenToSlots } from "../Renderer.js"; + +export function replaceSlotWithSlottedBricks< + T extends BrickConf | UseSingleBrickConf, +>( + brickConf: T, + hostContext: TemplateHostContext, + expand: (item: T, hostContext: TemplateHostContext) => T | T[] +): T[] { + // Currently, no support for `if` in a slot. + if ( + (brickConf.if != null && !brickConf.if) || + typeof brickConf.if === "string" + ) { + throw new Error( + `Can not use "if" in a slot currently, check your template "${hostContext.hostBrick.type}"` + ); + } + + const slot = String(brickConf.properties?.name ?? ""); + + // Currently, no support for expression as slot name. + if (isEvaluable(slot)) { + throw new Error( + `Can not use an expression as slot name "${slot}" currently, check your template "${hostContext.hostBrick.type}"` + ); + } + + // Do not repeat the same slot name in a template. + if (hostContext.usedSlots.has(slot)) { + throw new Error( + `Can not have multiple slots with the same name "${slot}", check your template "${hostContext.hostBrick.type}"` + ); + } + hostContext.usedSlots.add(slot); + + if ( + hostContext.externalSlots && + hasOwnProperty(hostContext.externalSlots, slot) + ) { + const insertBricks = hostContext.externalSlots[slot].bricks ?? []; + if (insertBricks.length > 0) { + const hostCtx = hostContext.hostBrick.runtimeContext; + // External bricks of a template, should not access the template internal forEach `ITEM`. + // For some existing templates who is *USING* this bug, we keep the old behavior. + const hostHasForEach = hasOwnProperty(hostCtx, "forEachItem"); + return ( + hostHasForEach + ? setupTemplateExternalBricksWithForEach( + insertBricks, + hostCtx.forEachItem, + hostCtx.forEachIndex!, + hostCtx.forEachSize! + ) + : insertBricks + ) as T[]; + } + } + + const defaultSlots = childrenToSlots(brickConf.children, brickConf.slots) as + | SlotsConfInTemplate + | undefined; + return (defaultSlots?.[""]?.bricks ?? []).flatMap((item) => + expand(item as T, hostContext) + ); +} diff --git a/packages/runtime/src/internal/CustomTemplates/setupTemplateProxy.ts b/packages/runtime/src/internal/CustomTemplates/setupTemplateProxy.ts index 2264d88d49..7406779362 100644 --- a/packages/runtime/src/internal/CustomTemplates/setupTemplateProxy.ts +++ b/packages/runtime/src/internal/CustomTemplates/setupTemplateProxy.ts @@ -16,7 +16,8 @@ import { childrenToSlots } from "../Renderer.js"; export function setupTemplateProxy( hostContext: TemplateHostContext, ref: string | undefined, - slots: SlotsConfOfBricks + slots: SlotsConfOfBricks, + slotted: boolean ) { const { reversedProxies, @@ -54,6 +55,13 @@ export function setupTemplateProxy( } const slotProxies = reversedProxies.slots.get(ref); + + if (slotProxies && slotted) { + throw new Error( + `Can not have proxied slot ref when the parent has a slot element child, check your template "${hostBrick.type}" and ref "${ref}"` + ); + } + if (slotProxies && externalSlots) { // Use an approach like template-literal's quasis: // `quasi0${0}quais1${1}quasi2...` @@ -131,7 +139,7 @@ export function setupTemplateProxy( } // External bricks of a template, have the same forEachItem context as their host. -function setupTemplateExternalBricksWithForEach( +export function setupTemplateExternalBricksWithForEach( bricks: BrickConf[], forEachItem: unknown, forEachIndex: number, diff --git a/packages/runtime/src/internal/CustomTemplates/setupUseBrickInTemplate.ts b/packages/runtime/src/internal/CustomTemplates/setupUseBrickInTemplate.ts index 9db0819293..fcafb9d9ef 100644 --- a/packages/runtime/src/internal/CustomTemplates/setupUseBrickInTemplate.ts +++ b/packages/runtime/src/internal/CustomTemplates/setupUseBrickInTemplate.ts @@ -1,8 +1,13 @@ -import type { UseBrickSlotsConf, UseSingleBrickConf } from "@next-core/types"; +import type { + UseBrickSlotConf, + UseBrickSlotsConf, + UseSingleBrickConf, +} from "@next-core/types"; import { isObject } from "@next-core/utils/general"; import type { TemplateHostContext } from "../interfaces.js"; import { setupTemplateProxy } from "./setupTemplateProxy.js"; import { childrenToSlots } from "../Renderer.js"; +import { replaceSlotWithSlottedBricks } from "./replaceSlotWithSlottedBricks.js"; export function setupUseBrickInTemplate( props: T, @@ -22,7 +27,7 @@ export function setupUseBrickInTemplate( .map(([key, value]) => isObject(value) && key === "useBrick" ? Array.isArray(value) - ? [key, value.map(setup)] + ? [key, value.flatMap((v) => setup(v))] : [key, setup(value as UseSingleBrickConf)] : [key, walk(value)] ) @@ -35,28 +40,40 @@ export function setupUseBrickInTemplate( ) as P; } - function setup(item: UseSingleBrickConf): UseSingleBrickConf { + function setup( + item: UseSingleBrickConf, + markSlotted?: () => void + ): UseSingleBrickConf | UseSingleBrickConf[] { + if (item.brick === "slot") { + markSlotted?.(); + return replaceSlotWithSlottedBricks(item, hostContext, (it) => setup(it)); + } + const { properties, slots: originalSlots, children, ...restConf } = item; const transpiledSlots = childrenToSlots(children, originalSlots) as | UseBrickSlotsConf | undefined; - const slots = Object.fromEntries( + let slotted = false; + const markChild = () => { + slotted = true; + }; + const slots = Object.fromEntries( Object.entries(transpiledSlots ?? {}).map(([slotName, slotConf]) => [ slotName, { type: "bricks", - bricks: (slotConf.bricks ?? []).map(setup), + bricks: (slotConf.bricks ?? []).flatMap((it) => setup(it, markChild)), }, ]) - ) as UseBrickSlotsConf; + ); return { ...restConf, properties: walk(properties), slots, - ...setupTemplateProxy(hostContext, restConf.ref, slots), + ...setupTemplateProxy(hostContext, restConf.ref, slots, slotted), }; } diff --git a/packages/runtime/src/internal/Renderer.spec.ts b/packages/runtime/src/internal/Renderer.spec.ts index aa443b979d..90217b9438 100644 --- a/packages/runtime/src/internal/Renderer.spec.ts +++ b/packages/runtime/src/internal/Renderer.spec.ts @@ -3302,6 +3302,154 @@ describe("renderBrick for tpl", () => { }); expect(loadBricksImperatively).toBeCalledWith(["unknown.tpl-x"], []); }); + + test("slots in tpl", async () => { + customTemplates.define("my.tpl-l", { + proxy: { + slots: { + other: { + ref: "main", + refSlot: "", + }, + }, + }, + bricks: [ + { brick: "h1", ref: "main", if: null! }, + { brick: "slot" }, + { brick: "h2" }, + { + brick: "my-use-brick", + properties: { + useBrick: { + brick: "div", + slots: { + "": { + bricks: [ + { + brick: "slot", + properties: { name: "use-brick" }, + }, + { + brick: "slot", + properties: { name: "no-used" }, + children: [{ brick: "em" }], + }, + ], + }, + }, + }, + }, + }, + ], + }); + + const container = document.createElement("div"); + const portal = document.createElement("div"); + const renderRoot = { + tag: RenderTag.ROOT, + container, + createPortal: portal, + } as RenderRoot; + const ctxStore = new DataStore("CTX"); + const runtimeContext = { + ctxStore, + tplStateStoreMap: new Map(), + pendingPermissionsPreCheck: [] as undefined[], + } as RuntimeContext; + const rendererContext = new RendererContext("page"); + + const output = await renderBrick( + renderRoot, + { + brick: "my.tpl-l", + children: [ + { brick: "p" }, + { brick: "hr" }, + { + brick: "em", + slot: "use-brick", + }, + ], + }, + runtimeContext, + rendererContext, + [], + {} + ); + + renderRoot.child = output.node; + + await Promise.all([...output.blockingList, ctxStore.waitForAll()]); + + mountTree(renderRoot); + expect(container.children).toMatchInlineSnapshot(` + HTMLCollection [ + +

+

+


+

+ + , + ] + `); + + unmountTree(container); + unmountTree(portal); + }); + + test("slots in tpl with proxies slot as well", async () => { + customTemplates.define("my.tpl-m", { + proxy: { + slots: { + other: { + ref: "main", + refSlot: "", + }, + }, + }, + bricks: [ + { + brick: "div", + ref: "main", + children: [{ brick: "h1" }, { brick: "slot" }, { brick: "h2" }], + }, + ], + }); + + const container = document.createElement("div"); + const portal = document.createElement("div"); + const renderRoot = { + tag: RenderTag.ROOT, + container, + createPortal: portal, + } as RenderRoot; + const ctxStore = new DataStore("CTX"); + const runtimeContext = { + ctxStore, + tplStateStoreMap: new Map(), + pendingPermissionsPreCheck: [] as undefined[], + } as RuntimeContext; + const rendererContext = new RendererContext("page"); + + await expect( + renderBrick( + renderRoot, + { + brick: "my.tpl-m", + children: [{ brick: "p" }, { brick: "hr" }], + }, + runtimeContext, + rendererContext, + [], + {} + ) + ).rejects.toMatchInlineSnapshot( + `[Error: Can not have proxied slot ref when the parent has a slot element child, check your template "my.tpl-m" and ref "main"]` + ); + }); }); describe("renderBrick for form renderer", () => { diff --git a/packages/runtime/src/internal/Renderer.ts b/packages/runtime/src/internal/Renderer.ts index 4b71907f85..147b000d5e 100644 --- a/packages/runtime/src/internal/Renderer.ts +++ b/packages/runtime/src/internal/Renderer.ts @@ -1248,8 +1248,8 @@ export function childrenToSlots( } if (Array.isArray(children) && !newSlots) { newSlots = {}; - for (const child of children) { - const slot = child.slot ?? ""; + for (const { slot: sl, ...child } of children) { + const slot = sl ?? ""; if (!hasOwnProperty(newSlots, slot)) { newSlots[slot] = { type: "bricks", diff --git a/packages/runtime/src/internal/data/DataStore.ts b/packages/runtime/src/internal/data/DataStore.ts index 83fed5639d..71d64f527c 100644 --- a/packages/runtime/src/internal/data/DataStore.ts +++ b/packages/runtime/src/internal/data/DataStore.ts @@ -566,7 +566,6 @@ export class DataStore { * dispose their data and pending tasks. */ disposeDataInRoutes(routes: RouteConf[]) { - // for (const route of routes) { const names = this.routeMap.get(route); if (names !== undefined) { diff --git a/packages/runtime/src/internal/interfaces.ts b/packages/runtime/src/internal/interfaces.ts index 24c0509750..f62e451766 100644 --- a/packages/runtime/src/internal/interfaces.ts +++ b/packages/runtime/src/internal/interfaces.ts @@ -131,6 +131,7 @@ export interface TemplateHostContext { externalSlots?: SlotsConfOfBricks; tplStateStoreId: string; hostBrick: TemplateHostBrick; + usedSlots: Set; } interface ReversedProxies {