├── output └── .gitkeep ├── .node-version ├── .gitignore ├── .oxfmtrc.json ├── .github ├── renovate.json └── workflows │ └── ci.yml ├── src ├── memory │ ├── oxc.js │ ├── babel.js │ └── swc.js ├── id.bench.ts └── transform.bench.ts ├── memory.sh ├── tsconfig.json ├── package.json ├── fixtures ├── UserSettings.tsx ├── table.tsx └── renderer.ts └── README.md /output/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | output/*.js 3 | .idea/ 4 | -------------------------------------------------------------------------------- /.oxfmtrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": ["fixtures"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>Boshen/renovate"], 4 | "rangeStrategy": "bump", 5 | "schedule": ["at any time"] 6 | } 7 | -------------------------------------------------------------------------------- /src/memory/oxc.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { transformSync } from "oxc-transform"; 3 | let filename = "./fixtures/parser.ts"; 4 | const sourceText = fs.readFileSync(filename, "utf8"); 5 | transformSync(filename, sourceText); 6 | -------------------------------------------------------------------------------- /memory.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo 'oxc' 4 | /usr/bin/time -alh node ./src/memory/oxc.js 5 | 6 | echo 'swc' 7 | /usr/bin/time -alh node ./src/memory/swc.js 8 | 9 | echo 'babel' 10 | /usr/bin/time -alh node ./src/memory/babel.js 11 | -------------------------------------------------------------------------------- /src/memory/babel.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { transformSync as babelTransform } from "@babel/core"; 3 | let filename = "./fixtures/parser.ts"; 4 | const sourceText = fs.readFileSync(filename, "utf8"); 5 | babelTransform(sourceText, { 6 | filename, 7 | babelrc: false, 8 | comments: false, 9 | presets: ["@babel/preset-typescript", ["@babel/preset-react", { runtime: "automatic" }]], 10 | }); 11 | -------------------------------------------------------------------------------- /src/memory/swc.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { transformSync as swcTransform } from "@swc/core"; 3 | let filename = "./fixtures/parser.ts"; 4 | const sourceText = fs.readFileSync(filename, "utf8"); 5 | swcTransform(sourceText, { 6 | filename, 7 | swcrc: false, 8 | jsc: { 9 | target: "esnext", 10 | transform: { 11 | treatConstEnumAsEnum: true, 12 | react: { 13 | runtime: "automatic", 14 | }, 15 | }, 16 | preserveAllComments: false, 17 | experimental: { 18 | disableAllLints: true, 19 | }, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "ES2022", 5 | "module": "ESNext", 6 | "lib": ["ES2023"], 7 | "skipLibCheck": true, 8 | 9 | /* Transpile with Vite */ 10 | "moduleResolution": "bundler", 11 | "noEmit": true, 12 | "isolatedModules": true, 13 | "allowImportingTsExtensions": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "useUnknownInCatchVariables": true, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bench-transformer", 3 | "private": true, 4 | "type": "module", 5 | "packageManager": "pnpm@10.26.0", 6 | "scripts": { 7 | "bench": "vitest bench --run", 8 | "fmt": "oxfmt" 9 | }, 10 | "devDependencies": { 11 | "@babel/core": "^7.26.7", 12 | "@babel/preset-env": "^7.26.7", 13 | "@babel/preset-react": "^7.26.3", 14 | "@babel/preset-typescript": "^7.26.0", 15 | "@swc/core": "^1.10.14", 16 | "@types/babel__core": "^7.20.5", 17 | "@types/node": "^25.0.0", 18 | "oxc-transform": "^0.104.0", 19 | "oxfmt": "^0.19.0", 20 | "react-refresh": "^0.18.0", 21 | "typescript": "^5.7.3", 22 | "vitest": "^4.0.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: [opened, synchronize] 7 | push: 8 | branches: 9 | - main 10 | 11 | permissions: {} 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} 15 | cancel-in-progress: ${{ github.ref_name != 'main' }} 16 | 17 | jobs: 18 | benchmark: 19 | name: Benchmark 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: taiki-e/checkout-action@b13d20b7cda4e2f325ef19895128f7ff735c0b3d # v1.3.1 23 | - uses: oxc-project/setup-node@141eb77546de6702f92d320926403fe3f9f6a6f2 # v1.0.5 24 | - run: pnpm install 25 | - run: pnpm run bench id 26 | - run: pnpm run bench transform 27 | -------------------------------------------------------------------------------- /src/id.bench.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import assert from "node:assert"; 3 | import { bench, describe } from "vitest"; 4 | import { transpileDeclaration } from "typescript"; 5 | import { isolatedDeclarationSync } from "oxc-transform"; 6 | 7 | function oxc(filename: string, sourceText: string) { 8 | return isolatedDeclarationSync(filename, sourceText).code; 9 | } 10 | 11 | function tsc(fileName: string, sourceText: string) { 12 | return transpileDeclaration(sourceText, { 13 | fileName, 14 | compilerOptions: { noResolve: true, noLib: true }, 15 | }).outputText; 16 | } 17 | 18 | const sources = fs.readdirSync("./fixtures").map((filename) => { 19 | const sourceText = fs.readFileSync(`./fixtures/${filename}`, "utf8"); 20 | return [filename, sourceText]; 21 | }); 22 | 23 | describe.each(sources)("%s", (filename, sourceText) => { 24 | for (const fn of [oxc, tsc]) { 25 | const code = fn(filename, sourceText); 26 | // fs.writeFileSync(`./output/${filename}.${fn.name}.js`, code); 27 | assert(code); 28 | bench(fn.name, () => void fn(filename, sourceText)); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /fixtures/UserSettings.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import { useEffect } from "react"; 3 | import { useForm } from "react-hook-form"; 4 | import { z } from "zod"; 5 | 6 | import dayjs from "@calcom/dayjs"; 7 | import { useTimePreferences } from "@calcom/features/bookings/lib"; 8 | import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; 9 | import { useLocale } from "@calcom/lib/hooks/useLocale"; 10 | import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; 11 | import { trpc } from "@calcom/trpc/react"; 12 | import { Button, TimezoneSelect, Icon, Input } from "@calcom/ui"; 13 | 14 | import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; 15 | 16 | interface IUserSettingsProps { 17 | nextStep: () => void; 18 | hideUsername?: boolean; 19 | } 20 | 21 | const UserSettings = (props: IUserSettingsProps) => { 22 | const { nextStep } = props; 23 | const [user] = trpc.viewer.me.useSuspenseQuery(); 24 | const { t } = useLocale(); 25 | const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences(); 26 | const telemetry = useTelemetry(); 27 | const userSettingsSchema = z.object({ 28 | name: z 29 | .string() 30 | .min(1) 31 | .max(FULL_NAME_LENGTH_MAX_LIMIT, { 32 | message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }), 33 | }), 34 | }); 35 | const { 36 | register, 37 | handleSubmit, 38 | formState: { errors }, 39 | } = useForm>({ 40 | defaultValues: { 41 | name: user?.name || "", 42 | }, 43 | reValidateMode: "onChange", 44 | resolver: zodResolver(userSettingsSchema), 45 | }); 46 | 47 | useEffect(() => { 48 | telemetry.event(telemetryEventTypes.onboardingStarted); 49 | }, [telemetry]); 50 | 51 | const utils = trpc.useUtils(); 52 | const onSuccess = async () => { 53 | await utils.viewer.me.invalidate(); 54 | nextStep(); 55 | }; 56 | const mutation = trpc.viewer.updateProfile.useMutation({ 57 | onSuccess: onSuccess, 58 | }); 59 | 60 | const onSubmit = handleSubmit((data) => { 61 | mutation.mutate({ 62 | name: data.name, 63 | timeZone: selectedTimeZone, 64 | }); 65 | }); 66 | 67 | return ( 68 |
69 |
70 | {/* Username textfield: when not coming from signup */} 71 | {!props.hideUsername && } 72 | 73 | {/* Full name textfield */} 74 |
75 | 78 | 88 | {errors.name && ( 89 |

90 | {errors.name.message} 91 |

92 | )} 93 |
94 | {/* Timezone select field */} 95 |
96 | 99 | 100 | setSelectedTimeZone(value)} 104 | className="mt-2 w-full rounded-md text-sm" 105 | /> 106 | 107 |

108 | {t("current_time")} {dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()} 109 |

110 |
111 |
112 | 120 |
121 | ); 122 | }; 123 | 124 | export { UserSettings }; 125 | -------------------------------------------------------------------------------- /src/transform.bench.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import assert from "node:assert"; 3 | import { bench, describe } from "vitest"; 4 | import { 5 | transformSync as swcTransform, 6 | transform as swcTransformAsync, 7 | type Options as SwcTransformOptions, 8 | } from "@swc/core"; 9 | import { 10 | transformSync as babelTransform, 11 | transformAsync as babelTransformAsync, 12 | type TransformOptions as BabelTransformOptions, 13 | } from "@babel/core"; 14 | import { 15 | transformSync as oxcTransform, 16 | transform as oxcTransformAsync, 17 | type TransformOptions as OxcTransformOptions, 18 | } from "oxc-transform"; 19 | 20 | const CONCURRENT_RUN_COUNT = 5; 21 | 22 | type RunOptions = { 23 | filename: string; 24 | sourceText: string; 25 | sourceMap: boolean; 26 | reactDev: boolean; 27 | target: "esnext" | "es2015"; 28 | }; 29 | 30 | function getOxcOptions(options: RunOptions): OxcTransformOptions { 31 | return { 32 | sourcemap: options.sourceMap, 33 | target: options.target, 34 | react: { 35 | runtime: "automatic", 36 | development: options.reactDev, 37 | refresh: options.reactDev ? {} : undefined, 38 | }, 39 | }; 40 | } 41 | 42 | function oxc(options: RunOptions) { 43 | return oxcTransform(options.filename, options.sourceText, getOxcOptions(options)); 44 | } 45 | 46 | async function oxcAsync(options: RunOptions) { 47 | return await oxcTransformAsync(options.filename, options.sourceText, getOxcOptions(options)); 48 | } 49 | 50 | function getSwcOptions(options: RunOptions): SwcTransformOptions { 51 | return { 52 | filename: options.filename, 53 | sourceMaps: options.sourceMap, 54 | swcrc: false, 55 | jsc: { 56 | target: options.target, 57 | transform: { 58 | treatConstEnumAsEnum: true, 59 | react: { 60 | runtime: "automatic", 61 | development: options.reactDev, 62 | refresh: options.reactDev, 63 | }, 64 | }, 65 | preserveAllComments: false, 66 | experimental: { 67 | disableAllLints: true, 68 | }, 69 | }, 70 | }; 71 | } 72 | 73 | function swc(options: RunOptions) { 74 | return swcTransform(options.sourceText, getSwcOptions(options)); 75 | } 76 | 77 | async function swcAsync(options: RunOptions) { 78 | return await swcTransformAsync(options.sourceText, getSwcOptions(options)); 79 | } 80 | 81 | function getBabelOptions(options: RunOptions): BabelTransformOptions { 82 | return { 83 | filename: options.filename, 84 | sourceMaps: options.sourceMap, 85 | babelrc: false, 86 | configFile: false, 87 | browserslistConfigFile: false, 88 | comments: false, 89 | compact: false, 90 | envName: "development", 91 | plugins: options.reactDev ? ["react-refresh/babel"] : [], 92 | presets: [ 93 | "@babel/preset-typescript", 94 | ["@babel/preset-react", { runtime: "automatic", development: options.reactDev }], 95 | ], 96 | }; 97 | } 98 | 99 | function babel(options: RunOptions) { 100 | return babelTransform(options.sourceText, getBabelOptions(options))!; 101 | } 102 | 103 | async function babelAsync(options: RunOptions) { 104 | return (await babelTransformAsync(options.sourceText, getBabelOptions(options)))!; 105 | } 106 | 107 | type Case = [ 108 | filename: RunOptions["filename"], 109 | sourceMap: RunOptions["sourceMap"], 110 | reactDev: RunOptions["reactDev"], 111 | target: RunOptions["target"], 112 | sourceText: RunOptions["sourceText"], 113 | ]; 114 | const cases = fs.readdirSync("./fixtures").flatMap((filename): Case[] => { 115 | const sourceText = fs.readFileSync(`./fixtures/${filename}`, "utf8"); 116 | const base: Case[] = [ 117 | [filename, false, false, "esnext", sourceText], 118 | [filename, false, false, "es2015", sourceText], 119 | [filename, true, false, "esnext", sourceText], 120 | ]; 121 | if (!filename.endsWith(".tsx")) return base; 122 | return [ 123 | ...base, 124 | [filename, false, true, "esnext", sourceText], 125 | [filename, true, true, "esnext", sourceText], 126 | ]; 127 | }); 128 | 129 | describe.each(cases)( 130 | "%s (sourceMap: %s, reactDev: %s, target: %s)", 131 | async (filename, sourceMap, reactDev, target, sourceText) => { 132 | for (const fn of [oxc, swc, babel]) { 133 | const options: RunOptions = { filename, sourceText, sourceMap, reactDev, target }; 134 | const code = fn(options).code; 135 | // fs.writeFileSync(`./output/${filename}.${fn.name}.js`, code); 136 | assert(code); 137 | bench(fn.name, () => { 138 | for (let i = 0; i < CONCURRENT_RUN_COUNT; i++) { 139 | void fn(options); 140 | } 141 | }); 142 | } 143 | 144 | if (!sourceMap && !reactDev && target === "es2015") { 145 | for (const fn of [oxcAsync, swcAsync, babelAsync]) { 146 | const options: RunOptions = { filename, sourceText, sourceMap, reactDev, target }; 147 | const code = (await fn(options)).code; 148 | // fs.writeFileSync(`./output/${filename}.${fn.name}.js`, code); 149 | assert(code); 150 | bench(fn.name, async () => { 151 | for (let i = 0; i < CONCURRENT_RUN_COUNT; i++) { 152 | await fn(options); 153 | } 154 | }); 155 | bench(fn.name + " (Promise.all)", async () => { 156 | const arr = []; 157 | for (let i = 0; i < CONCURRENT_RUN_COUNT; i++) { 158 | arr.push(fn(options)); 159 | } 160 | await Promise.all(arr); 161 | }); 162 | } 163 | } 164 | }, 165 | ); 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bench Oxc, Swc, and Babel Transformer 2 | 3 | ## Summary 4 | 5 | - Transform: Oxc is 3x - 5x faster than SWC, uses 20% less memory, and has smaller package size (2 MB vs SWC's 37 MB). 6 | - Transform: Oxc is 20x - 50x faster than Babel, uses 70% less memory, and is 19 MB smaller, with only 2 npm packages to install vs Babel's 170. 7 | - React development + React Refresh: Oxc is 5x faster than SWC, 50x faster than Babel. 8 | - TS isolated declarations `.d.ts` emit: Oxc is 40x faster than TSC on typical files, 20x faster on larger files. 9 | 10 | ## Transform / Transpile 11 | 12 | Oxc is 3x - 5x faster than swc, and 20x - 50x faster than Babel. 13 | 14 | React development + refresh is 6x faster than swc and 20x - 70x faster than Babel. 15 | 16 | ### GitHub Actions `ubuntu-latest` 17 | 18 | ``` 19 | oxc - src/transform.bench.ts > UserSettings.tsx (sourceMap: false, reactDev: false, target: esnext) 20 | 4.18x faster than swc 21 | 49.04x faster than babel 22 | oxc - src/transform.bench.ts > UserSettings.tsx (sourceMap: false, reactDev: false, target: es2015) 23 | 5.71x faster than swc 24 | 46.49x faster than babel 25 | oxc - src/transform.bench.ts > UserSettings.tsx (sourceMap: true, reactDev: false, target: esnext) 26 | 4.23x faster than swc 27 | 40.18x faster than babel 28 | oxc - src/transform.bench.ts > UserSettings.tsx (sourceMap: false, reactDev: true, target: esnext) 29 | 5.41x faster than swc 30 | 64.39x faster than babel 31 | oxc - src/transform.bench.ts > UserSettings.tsx (sourceMap: true, reactDev: true, target: esnext) 32 | 5.17x faster than swc 33 | 53.43x faster than babel 34 | oxc - src/transform.bench.ts > parser.ts (sourceMap: false, reactDev: false, target: esnext) 35 | 3.13x faster than swc 36 | 34.59x faster than babel 37 | oxc - src/transform.bench.ts > parser.ts (sourceMap: false, reactDev: false, target: es2015) 38 | 3.51x faster than swc 39 | 31.86x faster than babel 40 | oxc - src/transform.bench.ts > parser.ts (sourceMap: true, reactDev: false, target: esnext) 41 | 3.03x faster than swc 42 | 26.98x faster than babel 43 | oxc - src/transform.bench.ts > renderer.ts (sourceMap: false, reactDev: false, target: esnext) 44 | 3.21x faster than swc 45 | 23.76x faster than babel 46 | oxc - src/transform.bench.ts > renderer.ts (sourceMap: false, reactDev: false, target: es2015) 47 | 3.62x faster than swc 48 | 22.93x faster than babel 49 | oxc - src/transform.bench.ts > renderer.ts (sourceMap: true, reactDev: false, target: esnext) 50 | 3.11x faster than swc 51 | 18.77x faster than babel 52 | oxc - src/transform.bench.ts > table.tsx (sourceMap: false, reactDev: false, target: esnext) 53 | 3.74x faster than swc 54 | 30.57x faster than babel 55 | oxc - src/transform.bench.ts > table.tsx (sourceMap: false, reactDev: false, target: es2015) 56 | 4.32x faster than swc 57 | 30.96x faster than babel 58 | oxc - src/transform.bench.ts > table.tsx (sourceMap: true, reactDev: false, target: esnext) 59 | 3.59x faster than swc 60 | 26.30x faster than babel 61 | oxc - src/transform.bench.ts > table.tsx (sourceMap: false, reactDev: true, target: esnext) 62 | 4.71x faster than swc 63 | 46.03x faster than babel 64 | oxc - src/transform.bench.ts > table.tsx (sourceMap: true, reactDev: true, target: esnext) 65 | 4.45x faster than swc 66 | 37.05x faster than babel 67 | ``` 68 | 69 | ## Isolated Declarations DTS Emit 70 | 71 | Oxc is 45x faster than `tsc` on ordinary files, and 20x faster on larger files. 72 | 73 | ### GitHub Actions `ubuntu-latest` 74 | 75 | ``` 76 | oxc - src/id.bench.ts > UserSettings.tsx 77 | 44.45x faster than tsc 78 | 79 | oxc - src/id.bench.ts > parser.ts 80 | 21.16x faster than tsc 81 | 82 | oxc - src/id.bench.ts > renderer.ts 83 | 21.70x faster than tsc 84 | 85 | oxc - src/id.bench.ts > table.tsx 86 | 7.99x faster than tsc 87 | ``` 88 | 89 | ### Memory Usage 90 | 91 | On `parser.ts` by using `/usr/bin/time -alh node`: 92 | 93 | | | Max RSS | 94 | | ----- | ------- | 95 | | oxc | 57 MB | 96 | | swc | 74 MB | 97 | | babel | 180 MB | 98 | 99 | ## Package size 100 | 101 | For package download size, oxc downloads 2 packages for around a total of 2MB. 102 | 103 | | Package | Size | 104 | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | 105 | | `@oxc-transform/binding-darwin-arm64` | [1.95 MB](https://www.npmjs.com/package/@oxc-transform/binding-darwin-arm64) | 106 | | `@swc/core-darwin-arm64` | [37.5 MB](https://www.npmjs.com/package/@swc/core-darwin-arm64) | 107 | | `@babel/core` + `@babel/preset-env` + `@babel/preset-react` + `@babel/preset-typescript` | [21MB and 170 packages](https://www.npmjs.com/package/@oxc-transform/binding-darwin-arm64) | 108 | 109 | ## Fixtures 110 | 111 | - [TypeScript/src/compiler/parser.ts](https://github.com/microsoft/TypeScript/blob/3ad0f752482f5e846dc35a69572ccb43311826c0/src/compiler/parser.ts) - an atypical large file with 10777 lines. 112 | - [vuejs/core/packages/runtime-core/src/renderer.ts](https://github.com/vuejs/core/blob/cb34b28a4a9bf868be4785b001c526163eda342e/packages/runtime-core/src/renderer.ts) - somewhat large library file with 2550 lines. 113 | - [AFFiNE/packages/frontend/core/src/components/affine/page-properties/table.tsx](https://github.com/toeverything/AFFiNE/blob/a9b29d24f1f6e5563e43a11b5cbcfb30c9981d25/packages/frontend/core/src/components/affine/page-properties/table.tsx) - a tsx file with 1118 lines. 114 | - [cal.com/apps/web/components/getting-started/steps-views/UserSettings.tsx](https://github.com/calcom/cal.com/blob/20729b3a4e62c52f49419d2c3b30225f0c7a5936/apps/web/components/getting-started/steps-views/UserSettings.tsx) - a typical 124 lines of tsx code. 115 | 116 | ### NOTE: 117 | 118 | I intended to benchmark `checker.ts` from tsc, but Babel failed to parse: 119 | 120 | ``` 121 | TypeError: Duplicate declaration "SymbolLinks" 122 | 1425 | })); 123 | 1426 | 124 | > 1427 | const SymbolLinks = class implements SymbolLinks { 125 | | ^^^^^^^^^^^ 126 | 1428 | declare _symbolLinksBrand: any; 127 | 1429 | }; 128 | 1430 | 129 | ``` 130 | -------------------------------------------------------------------------------- /fixtures/table.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuProps } from '@affine/component'; 2 | import { Button, IconButton, Menu, MenuItem, Tooltip } from '@affine/component'; 3 | import { useCurrentWorkspacePropertiesAdapter } from '@affine/core/components/hooks/use-affine-adapter'; 4 | import { DocLinksService } from '@affine/core/modules/doc-link'; 5 | import { EditorSettingService } from '@affine/core/modules/editor-settting'; 6 | import type { 7 | PageInfoCustomProperty, 8 | PageInfoCustomPropertyMeta, 9 | PagePropertyType, 10 | } from '@affine/core/modules/properties/services/schema'; 11 | import { i18nTime, useI18n } from '@affine/i18n'; 12 | import { track } from '@affine/track'; 13 | import { assertExists } from '@blocksuite/affine/global/utils'; 14 | import { 15 | ArrowDownSmallIcon, 16 | DeleteIcon, 17 | InvisibleIcon, 18 | MoreHorizontalIcon, 19 | PlusIcon, 20 | TagsIcon, 21 | ToggleExpandIcon, 22 | ViewIcon, 23 | } from '@blocksuite/icons/rc'; 24 | import type { DragEndEvent, DraggableAttributes } from '@dnd-kit/core'; 25 | import { 26 | DndContext, 27 | PointerSensor, 28 | useSensor, 29 | useSensors, 30 | } from '@dnd-kit/core'; 31 | import { 32 | restrictToParentElement, 33 | restrictToVerticalAxis, 34 | } from '@dnd-kit/modifiers'; 35 | import { SortableContext, useSortable } from '@dnd-kit/sortable'; 36 | import * as Collapsible from '@radix-ui/react-collapsible'; 37 | import { 38 | DocService, 39 | useLiveData, 40 | useServices, 41 | WorkspaceService, 42 | } from '@toeverything/infra'; 43 | import clsx from 'clsx'; 44 | import { use } from 'foxact/use'; 45 | import { useDebouncedValue } from 'foxact/use-debounced-value'; 46 | import { atom, useAtomValue, useSetAtom } from 'jotai'; 47 | import type React from 'react'; 48 | import type { 49 | CSSProperties, 50 | MouseEvent, 51 | MouseEventHandler, 52 | PropsWithChildren, 53 | } from 'react'; 54 | import { 55 | Suspense, 56 | useCallback, 57 | useContext, 58 | useEffect, 59 | useMemo, 60 | useState, 61 | } from 'react'; 62 | 63 | import { AffinePageReference } from '../reference-link'; 64 | import { managerContext } from './common'; 65 | import { ConfirmDeletePropertyModal } from './confirm-delete-property-modal'; 66 | import type { PagePropertyIcon } from './icons-mapping'; 67 | import { getDefaultIconName, nameToIcon } from './icons-mapping'; 68 | import type { MenuItemOption } from './menu-items'; 69 | import { 70 | EditPropertyNameMenuItem, 71 | PropertyTypeMenuItem, 72 | renderMenuItemOptions, 73 | } from './menu-items'; 74 | import type { PagePropertiesMetaManager } from './page-properties-manager'; 75 | import { 76 | newPropertyTypes, 77 | PagePropertiesManager, 78 | } from './page-properties-manager'; 79 | import { 80 | propertyValueRenderers, 81 | TagsValue, 82 | } from './property-row-value-renderer'; 83 | import * as styles from './styles.css'; 84 | 85 | type PagePropertiesSettingsPopupProps = PropsWithChildren<{ 86 | className?: string; 87 | style?: React.CSSProperties; 88 | }>; 89 | 90 | const Divider = () =>
; 91 | 92 | type PropertyVisibility = PageInfoCustomProperty['visibility']; 93 | 94 | const editingPropertyAtom = atom(null); 95 | 96 | const modifiers = [restrictToParentElement, restrictToVerticalAxis]; 97 | 98 | interface SortablePropertiesProps { 99 | children: (properties: PageInfoCustomProperty[]) => React.ReactNode; 100 | } 101 | 102 | export const SortableProperties = ({ children }: SortablePropertiesProps) => { 103 | const manager = useContext(managerContext); 104 | const properties = useMemo(() => manager.sorter.getOrderedItems(), [manager]); 105 | const editingItem = useAtomValue(editingPropertyAtom); 106 | const draggable = !manager.readonly && !editingItem; 107 | const sensors = useSensors( 108 | useSensor(PointerSensor, { 109 | activationConstraint: { 110 | distance: 8, 111 | }, 112 | }) 113 | ); 114 | // use localProperties since changes applied to upstream may be delayed 115 | // if we use that one, there will be weird behavior after reordering 116 | const [localProperties, setLocalProperties] = useState(properties); 117 | 118 | useEffect(() => { 119 | setLocalProperties(properties); 120 | }, [properties]); 121 | 122 | const onDragEnd = useCallback( 123 | (event: DragEndEvent) => { 124 | if (!draggable) { 125 | return; 126 | } 127 | const { active, over } = event; 128 | if (over) { 129 | manager.sorter.move(active.id, over.id); 130 | } 131 | setLocalProperties(manager.sorter.getOrderedItems()); 132 | }, 133 | [manager, draggable] 134 | ); 135 | 136 | const filteredProperties = useMemo( 137 | () => localProperties.filter(p => manager.getCustomPropertyMeta(p.id)), 138 | [localProperties, manager] 139 | ); 140 | 141 | return ( 142 | 143 | 144 | {children(filteredProperties)} 145 | 146 | 147 | ); 148 | }; 149 | 150 | type SyntheticListenerMap = ReturnType['listeners']; 151 | 152 | const SortablePropertyRow = ({ 153 | property, 154 | className, 155 | children, 156 | ...props 157 | }: { 158 | property: PageInfoCustomProperty; 159 | className?: string; 160 | children?: 161 | | React.ReactNode 162 | | ((props: { 163 | attributes: DraggableAttributes; 164 | listeners?: SyntheticListenerMap; 165 | }) => React.ReactNode); 166 | }) => { 167 | const manager = useContext(managerContext); 168 | const { 169 | setNodeRef, 170 | attributes, 171 | listeners, 172 | transform, 173 | transition, 174 | active, 175 | isDragging, 176 | isSorting, 177 | } = useSortable({ 178 | id: property.id, 179 | }); 180 | const style: CSSProperties = useMemo( 181 | () => ({ 182 | transform: transform 183 | ? `translate3d(${transform.x}px, ${transform.y}px, 0)` 184 | : undefined, 185 | transition: isSorting ? transition : undefined, 186 | pointerEvents: manager.readonly ? 'none' : undefined, 187 | }), 188 | [isSorting, manager.readonly, transform, transition] 189 | ); 190 | 191 | return ( 192 |
204 | {typeof children === 'function' 205 | ? children({ attributes, listeners }) 206 | : children} 207 |
208 | ); 209 | }; 210 | 211 | const visibilities: PropertyVisibility[] = ['visible', 'hide', 'hide-if-empty']; 212 | const rotateVisibility = ( 213 | visibility: PropertyVisibility 214 | ): PropertyVisibility => { 215 | const index = visibilities.indexOf(visibility); 216 | return visibilities[(index + 1) % visibilities.length]; 217 | }; 218 | 219 | const visibilityMenuText = (visibility: PropertyVisibility = 'visible') => { 220 | switch (visibility) { 221 | case 'hide': 222 | return 'com.affine.page-properties.property.hide-in-view'; 223 | case 'hide-if-empty': 224 | return 'com.affine.page-properties.property.hide-in-view-when-empty'; 225 | case 'visible': 226 | return 'com.affine.page-properties.property.show-in-view'; 227 | default: 228 | throw new Error(`unknown visibility: ${visibility}`); 229 | } 230 | }; 231 | 232 | const visibilitySelectorText = (visibility: PropertyVisibility = 'visible') => { 233 | switch (visibility) { 234 | case 'hide': 235 | return 'com.affine.page-properties.property.always-hide'; 236 | case 'hide-if-empty': 237 | return 'com.affine.page-properties.property.hide-when-empty'; 238 | case 'visible': 239 | return 'com.affine.page-properties.property.always-show'; 240 | default: 241 | throw new Error(`unknown visibility: ${visibility}`); 242 | } 243 | }; 244 | 245 | const VisibilityModeSelector = ({ 246 | property, 247 | }: { 248 | property: PageInfoCustomProperty; 249 | }) => { 250 | const manager = useContext(managerContext); 251 | const t = useI18n(); 252 | const meta = manager.getCustomPropertyMeta(property.id); 253 | const visibility = property.visibility || 'visible'; 254 | 255 | const menuItems = useMemo(() => { 256 | const options: MenuItemOption[] = []; 257 | options.push( 258 | visibilities.map(v => { 259 | const text = visibilityMenuText(v); 260 | return { 261 | text: t[text](), 262 | selected: visibility === v, 263 | onClick: () => { 264 | manager.updateCustomProperty(property.id, { 265 | visibility: v, 266 | }); 267 | }, 268 | }; 269 | }) 270 | ); 271 | return renderMenuItemOptions(options); 272 | }, [manager, property.id, t, visibility]); 273 | 274 | if (!meta) { 275 | return null; 276 | } 277 | 278 | const required = meta.required; 279 | 280 | return ( 281 | 292 |
297 | {required ? ( 298 | t['com.affine.page-properties.property.required']() 299 | ) : ( 300 | <> 301 | {t[visibilitySelectorText(visibility)]()} 302 | 303 | 304 | )} 305 |
306 |
307 | ); 308 | }; 309 | 310 | export const PagePropertiesSettingsPopup = ({ 311 | children, 312 | }: PagePropertiesSettingsPopupProps) => { 313 | const manager = useContext(managerContext); 314 | const t = useI18n(); 315 | 316 | const menuItems = useMemo(() => { 317 | const options: MenuItemOption[] = []; 318 | options.push( 319 |
324 | {t['com.affine.page-properties.settings.title']()} 325 |
326 | ); 327 | options.push('-'); 328 | options.push([ 329 | 330 | {properties => 331 | properties.map(property => { 332 | const meta = manager.getCustomPropertyMeta(property.id); 333 | assertExists(meta, 'meta should exist for property'); 334 | const Icon = nameToIcon(meta.icon, meta.type); 335 | const name = meta.name; 336 | return ( 337 | 343 | 344 |
348 | {name} 349 |
350 | 351 |
352 | ); 353 | }) 354 | } 355 |
, 356 | ]); 357 | return renderMenuItemOptions(options); 358 | }, [manager, t]); 359 | 360 | return ( 361 | 369 | {children} 370 | 371 | ); 372 | }; 373 | 374 | type PageBacklinksPopupProps = PropsWithChildren<{ 375 | backlinks: { docId: string; blockId: string; title: string }[]; 376 | }>; 377 | 378 | export const PageBacklinksPopup = ({ 379 | backlinks, 380 | children, 381 | }: PageBacklinksPopupProps) => { 382 | return ( 383 | 391 | {backlinks.map(link => ( 392 | 397 | ))} 398 |
399 | } 400 | > 401 | {children} 402 | 403 | ); 404 | }; 405 | 406 | interface PagePropertyRowNameProps { 407 | property: PageInfoCustomProperty; 408 | meta: PageInfoCustomPropertyMeta; 409 | editing: boolean; 410 | onFinishEditing: () => void; 411 | } 412 | 413 | export const PagePropertyRowNameMenu = ({ 414 | editing, 415 | meta, 416 | property, 417 | onFinishEditing, 418 | children, 419 | }: PropsWithChildren) => { 420 | const manager = useContext(managerContext); 421 | const [localPropertyMeta, setLocalPropertyMeta] = useState(() => ({ 422 | ...meta, 423 | })); 424 | const [localProperty, setLocalProperty] = useState(() => ({ ...property })); 425 | const nextVisibility = rotateVisibility(localProperty.visibility); 426 | 427 | const [showDeleteModal, setShowDeleteModal] = useState(false); 428 | 429 | useEffect(() => { 430 | setLocalPropertyMeta(meta); 431 | }, [meta]); 432 | 433 | useEffect(() => { 434 | setLocalProperty(property); 435 | }, [property]); 436 | 437 | const handleFinishEditing = useCallback(() => { 438 | onFinishEditing(); 439 | manager.updateCustomPropertyMeta(meta.id, localPropertyMeta); 440 | manager.updateCustomProperty(property.id, localProperty); 441 | }, [ 442 | localProperty, 443 | localPropertyMeta, 444 | manager, 445 | meta.id, 446 | onFinishEditing, 447 | property.id, 448 | ]); 449 | const t = useI18n(); 450 | const handleNameBlur = useCallback( 451 | (v: string) => { 452 | manager.updateCustomPropertyMeta(meta.id, { 453 | name: v, 454 | }); 455 | }, 456 | [manager, meta.id] 457 | ); 458 | const handleNameChange: (name: string) => void = useCallback(name => { 459 | setLocalPropertyMeta(prev => ({ 460 | ...prev, 461 | name: name, 462 | })); 463 | }, []); 464 | const toggleHide = useCallback( 465 | (e: MouseEvent) => { 466 | e.stopPropagation(); 467 | e.preventDefault(); 468 | setLocalProperty(prev => ({ 469 | ...prev, 470 | visibility: nextVisibility, 471 | })); 472 | }, 473 | [nextVisibility] 474 | ); 475 | const handleDelete = useCallback(() => { 476 | manager.removeCustomProperty(property.id); 477 | }, [manager, property.id]); 478 | 479 | const handleIconChange = useCallback( 480 | (icon: PagePropertyIcon) => { 481 | setLocalPropertyMeta(prev => ({ 482 | ...prev, 483 | icon, 484 | })); 485 | manager.updateCustomPropertyMeta(meta.id, { 486 | icon: icon, 487 | }); 488 | }, 489 | [manager, meta.id] 490 | ); 491 | 492 | const menuItems = useMemo(() => { 493 | const options: MenuItemOption[] = []; 494 | options.push( 495 | 501 | ); 502 | options.push(); 503 | if (!localPropertyMeta.required) { 504 | options.push('-'); 505 | options.push({ 506 | icon: 507 | nextVisibility === 'hide' || nextVisibility === 'hide-if-empty' ? ( 508 | 509 | ) : ( 510 | 511 | ), 512 | text: t[ 513 | visibilityMenuText(rotateVisibility(localProperty.visibility)) 514 | ](), 515 | onClick: toggleHide, 516 | }); 517 | options.push({ 518 | type: 'danger', 519 | icon: , 520 | text: t['com.affine.page-properties.property.remove-property'](), 521 | onClick: () => setShowDeleteModal(true), 522 | }); 523 | } 524 | return renderMenuItemOptions(options); 525 | }, [ 526 | handleIconChange, 527 | handleNameBlur, 528 | handleNameChange, 529 | localProperty.visibility, 530 | localPropertyMeta, 531 | nextVisibility, 532 | t, 533 | toggleHide, 534 | ]); 535 | 536 | return ( 537 | <> 538 | 555 | {children} 556 | 557 | { 559 | setShowDeleteModal(false); 560 | handleDelete(); 561 | }} 562 | onCancel={() => setShowDeleteModal(false)} 563 | show={showDeleteModal} 564 | property={meta} 565 | /> 566 | 567 | ); 568 | }; 569 | 570 | interface PagePropertiesTableHeaderProps { 571 | className?: string; 572 | style?: React.CSSProperties; 573 | open: boolean; 574 | onOpenChange: (open: boolean) => void; 575 | } 576 | 577 | // backlinks - #no Updated yyyy-mm-dd 578 | // ───────────────────────────────────────────────── 579 | // Page Info ... 580 | export const PagePropertiesTableHeader = ({ 581 | className, 582 | style, 583 | open, 584 | onOpenChange, 585 | }: PagePropertiesTableHeaderProps) => { 586 | const manager = useContext(managerContext); 587 | 588 | const t = useI18n(); 589 | const { 590 | docLinksServices, 591 | docService, 592 | workspaceService, 593 | editorSettingService, 594 | } = useServices({ 595 | DocLinksServices: DocLinksService, 596 | DocService, 597 | WorkspaceService, 598 | EditorSettingService, 599 | }); 600 | const docBacklinks = docLinksServices.backlinks; 601 | const backlinks = useLiveData(docBacklinks.backlinks$); 602 | 603 | const displayDocInfo = useLiveData( 604 | editorSettingService.editorSetting.settings$.selector(s => s.displayDocInfo) 605 | ); 606 | 607 | const { syncing, retrying, serverClock } = useLiveData( 608 | workspaceService.workspace.engine.doc.docState$(docService.doc.id) 609 | ); 610 | 611 | const timestampElement = useMemo(() => { 612 | const localizedCreateTime = manager.createDate 613 | ? i18nTime(manager.createDate) 614 | : null; 615 | 616 | const createTimeElement = ( 617 |
618 | {t['Created']()} {localizedCreateTime} 619 |
620 | ); 621 | 622 | return serverClock ? ( 623 | 627 |
628 | {t['Updated']()} {i18nTime(serverClock)} 629 |
630 | {manager.createDate && ( 631 |
632 | {t['Created']()} {i18nTime(manager.createDate)} 633 |
634 | )} 635 | 636 | } 637 | > 638 |
639 | {!syncing && !retrying ? ( 640 | <> 641 | {t['Updated']()}{' '} 642 | {i18nTime(serverClock, { 643 | relative: { 644 | max: [1, 'day'], 645 | accuracy: 'minute', 646 | }, 647 | absolute: { 648 | accuracy: 'day', 649 | }, 650 | })} 651 | 652 | ) : ( 653 | <>{t['com.affine.syncing']()} 654 | )} 655 |
656 |
657 | ) : manager.updatedDate ? ( 658 | 659 |
660 | {t['Updated']()} {i18nTime(manager.updatedDate)} 661 |
662 |
663 | ) : ( 664 | createTimeElement 665 | ); 666 | }, [ 667 | manager.createDate, 668 | manager.updatedDate, 669 | retrying, 670 | serverClock, 671 | syncing, 672 | t, 673 | ]); 674 | 675 | const dTimestampElement = useDebouncedValue(timestampElement, 500); 676 | 677 | const handleCollapse = useCallback(() => { 678 | track.doc.inlineDocInfo.$.toggle(); 679 | onOpenChange(!open); 680 | }, [onOpenChange, open]); 681 | 682 | const properties = manager.sorter.getOrderedItems(); 683 | 684 | return ( 685 |
686 | {/* TODO(@Peng): add click handler to backlinks */} 687 |
688 | {backlinks.length > 0 ? ( 689 | 690 |
691 | {t['com.affine.page-properties.backlinks']()} · {backlinks.length} 692 |
693 |
694 | ) : null} 695 | {dTimestampElement} 696 |
697 | 698 | {displayDocInfo ? ( 699 |
700 |
701 | {t['com.affine.page-properties.page-info']()} 702 |
703 | {properties.length === 0 || manager.readonly ? null : ( 704 | 705 | 706 | 707 | 708 | 709 | )} 710 | 711 |
715 | 716 | 720 | 721 |
722 |
723 |
724 | ) : null} 725 |
726 | ); 727 | }; 728 | 729 | interface PagePropertyRowProps { 730 | property: PageInfoCustomProperty; 731 | style?: React.CSSProperties; 732 | rowNameClassName?: string; 733 | } 734 | 735 | export const PagePropertyRow = ({ 736 | property, 737 | rowNameClassName, 738 | }: PagePropertyRowProps) => { 739 | const manager = useContext(managerContext); 740 | const meta = manager.getCustomPropertyMeta(property.id); 741 | 742 | assertExists(meta, 'meta should exist for property'); 743 | 744 | const Icon = nameToIcon(meta.icon, meta.type); 745 | const name = meta.name; 746 | const ValueRenderer = propertyValueRenderers[meta.type]; 747 | const [editingMeta, setEditingMeta] = useState(false); 748 | const setEditingItem = useSetAtom(editingPropertyAtom); 749 | const handleEditMeta = useCallback(() => { 750 | if (!manager.readonly) { 751 | setEditingMeta(true); 752 | } 753 | setEditingItem(property.id); 754 | }, [manager.readonly, property.id, setEditingItem]); 755 | const handleFinishEditingMeta = useCallback(() => { 756 | setEditingMeta(false); 757 | setEditingItem(null); 758 | }, [setEditingItem]); 759 | 760 | // NOTE: if we define a new property type, the value render may not exists in old client 761 | // skip rendering if value render is not define yet 762 | if (!ValueRenderer || typeof ValueRenderer !== 'function') return null; 763 | 764 | return ( 765 | 766 | {({ attributes, listeners }) => ( 767 | <> 768 | 774 |
784 |
785 |
786 | 787 |
788 |
{name}
789 |
790 |
791 |
792 | 793 | 794 | )} 795 |
796 | ); 797 | }; 798 | 799 | export const PageTagsRow = ({ 800 | rowNameClassName, 801 | }: { 802 | rowNameClassName?: string; 803 | }) => { 804 | const t = useI18n(); 805 | return ( 806 |
811 |
815 |
816 |
817 | 818 |
819 |
{t['Tags']()}
820 |
821 |
822 | 823 |
824 | ); 825 | }; 826 | 827 | interface PagePropertiesTableBodyProps { 828 | className?: string; 829 | style?: React.CSSProperties; 830 | } 831 | 832 | // 🏷️ Tags (⋅ xxx) (⋅ yyy) 833 | // #️⃣ Number 123456 834 | // + Add a property 835 | export const PagePropertiesTableBody = ({ 836 | className, 837 | style, 838 | }: PagePropertiesTableBodyProps) => { 839 | const manager = useContext(managerContext); 840 | return ( 841 | 845 | 846 | 847 | {properties => 848 | properties.length ? ( 849 |
850 | {properties 851 | .filter( 852 | property => 853 | manager.isPropertyRequired(property.id) || 854 | (property.visibility !== 'hide' && 855 | !( 856 | property.visibility === 'hide-if-empty' && 857 | !property.value 858 | )) 859 | ) 860 | .map(property => ( 861 | 862 | ))} 863 |
864 | ) : null 865 | } 866 |
867 | {manager.readonly ? null : } 868 | 869 |
870 | ); 871 | }; 872 | 873 | interface PagePropertiesCreatePropertyMenuItemsProps { 874 | onCreated?: (e: React.MouseEvent, id: string) => void; 875 | metaManager: PagePropertiesMetaManager; 876 | } 877 | 878 | const findNextDefaultName = (name: string, allNames: string[]): string => { 879 | const nameExists = allNames.includes(name); 880 | if (nameExists) { 881 | const match = name.match(/(\d+)$/); 882 | if (match) { 883 | const num = parseInt(match[1], 10); 884 | const nextName = name.replace(/(\d+)$/, `${num + 1}`); 885 | return findNextDefaultName(nextName, allNames); 886 | } else { 887 | return findNextDefaultName(`${name} 2`, allNames); 888 | } 889 | } else { 890 | return name; 891 | } 892 | }; 893 | 894 | export const PagePropertiesCreatePropertyMenuItems = ({ 895 | onCreated, 896 | metaManager, 897 | }: PagePropertiesCreatePropertyMenuItemsProps) => { 898 | const t = useI18n(); 899 | const onAddProperty = useCallback( 900 | ( 901 | e: React.MouseEvent, 902 | option: { type: PagePropertyType; name: string; icon: string } 903 | ) => { 904 | const schemaList = metaManager.getOrderedPropertiesSchema(); 905 | const nameExists = schemaList.some(meta => meta.name === option.name); 906 | const allNames = schemaList.map(meta => meta.name); 907 | const name = nameExists 908 | ? findNextDefaultName(option.name, allNames) 909 | : option.name; 910 | const { id } = metaManager.addPropertyMeta({ 911 | name, 912 | icon: option.icon, 913 | type: option.type, 914 | }); 915 | onCreated?.(e, id); 916 | }, 917 | [metaManager, onCreated] 918 | ); 919 | 920 | return useMemo(() => { 921 | const options: MenuItemOption[] = []; 922 | options.push( 923 |
924 | {t['com.affine.page-properties.create-property.menu.header']()} 925 |
926 | ); 927 | options.push('-'); 928 | options.push( 929 | newPropertyTypes.map(type => { 930 | const iconName = getDefaultIconName(type); 931 | const Icon = nameToIcon(iconName, type); 932 | const name = t[`com.affine.page-properties.property.${type}`](); 933 | return { 934 | icon: , 935 | text: name, 936 | onClick: (e: React.MouseEvent) => { 937 | onAddProperty(e, { 938 | icon: iconName, 939 | name: name, 940 | type: type, 941 | }); 942 | }, 943 | }; 944 | }) 945 | ); 946 | return renderMenuItemOptions(options); 947 | }, [onAddProperty, t]); 948 | }; 949 | 950 | interface PagePropertiesAddPropertyMenuItemsProps { 951 | onCreateClicked: (e: React.MouseEvent) => void; 952 | } 953 | 954 | const PagePropertiesAddPropertyMenuItems = ({ 955 | onCreateClicked, 956 | }: PagePropertiesAddPropertyMenuItemsProps) => { 957 | const manager = useContext(managerContext); 958 | 959 | const t = useI18n(); 960 | const metaList = manager.metaManager.getOrderedPropertiesSchema(); 961 | const nonRequiredMetaList = metaList.filter(meta => !meta.required); 962 | const isChecked = useCallback( 963 | (m: string) => { 964 | return manager.hasCustomProperty(m); 965 | }, 966 | [manager] 967 | ); 968 | 969 | const onClickProperty = useCallback( 970 | (e: React.MouseEvent, id: string) => { 971 | e.stopPropagation(); 972 | e.preventDefault(); 973 | if (isChecked(id)) { 974 | manager.removeCustomProperty(id); 975 | } else { 976 | manager.addCustomProperty(id); 977 | } 978 | }, 979 | [isChecked, manager] 980 | ); 981 | 982 | const menuItems = useMemo(() => { 983 | const options: MenuItemOption[] = []; 984 | options.push( 985 |
986 | {t['com.affine.page-properties.add-property.menu.header']()} 987 |
988 | ); 989 | 990 | if (nonRequiredMetaList.length > 0) { 991 | options.push('-'); 992 | const nonRequiredMetaOptions: MenuItemOption = nonRequiredMetaList.map( 993 | meta => { 994 | const Icon = nameToIcon(meta.icon, meta.type); 995 | const name = meta.name; 996 | return { 997 | icon: , 998 | text: name, 999 | selected: isChecked(meta.id), 1000 | onClick: (e: React.MouseEvent) => onClickProperty(e, meta.id), 1001 | }; 1002 | } 1003 | ); 1004 | options.push(nonRequiredMetaOptions); 1005 | } 1006 | options.push('-'); 1007 | options.push({ 1008 | icon: , 1009 | text: t['com.affine.page-properties.add-property.menu.create'](), 1010 | onClick: onCreateClicked, 1011 | }); 1012 | 1013 | return renderMenuItemOptions(options); 1014 | }, [isChecked, nonRequiredMetaList, onClickProperty, onCreateClicked, t]); 1015 | 1016 | return menuItems; 1017 | }; 1018 | 1019 | export const PagePropertiesAddProperty = () => { 1020 | const t = useI18n(); 1021 | const [adding, setAdding] = useState(true); 1022 | const manager = useContext(managerContext); 1023 | const toggleAdding: MouseEventHandler = useCallback(e => { 1024 | e.stopPropagation(); 1025 | e.preventDefault(); 1026 | setAdding(prev => !prev); 1027 | }, []); 1028 | 1029 | const menuOptions = useMemo(() => { 1030 | const handleCreated = (e: React.MouseEvent, id: string) => { 1031 | toggleAdding(e); 1032 | manager.addCustomProperty(id); 1033 | }; 1034 | const items = adding ? ( 1035 | 1036 | ) : ( 1037 | 1041 | ); 1042 | 1043 | return { 1044 | contentOptions: { 1045 | onClick(e) { 1046 | e.stopPropagation(); 1047 | }, 1048 | }, 1049 | rootOptions: { 1050 | onOpenChange: () => setAdding(true), 1051 | }, 1052 | items, 1053 | } satisfies Partial; 1054 | }, [adding, manager, toggleAdding]); 1055 | 1056 | return ( 1057 | 1058 | 1065 | 1066 | ); 1067 | }; 1068 | 1069 | const PagePropertiesTableInner = () => { 1070 | const manager = useContext(managerContext); 1071 | const [expanded, setExpanded] = useState(false); 1072 | use(manager.workspace.docCollection.doc.whenSynced); 1073 | return ( 1074 |
1075 | 1080 | 1081 | 1082 | 1083 |
1084 | ); 1085 | }; 1086 | 1087 | export const usePagePropertiesManager = (docId: string) => { 1088 | // the workspace properties adapter adapter is reactive, 1089 | // which means it's reference will change when any of the properties change 1090 | // also it will trigger a re-render of the component 1091 | const adapter = useCurrentWorkspacePropertiesAdapter(); 1092 | const manager = useMemo(() => { 1093 | return new PagePropertiesManager(adapter, docId); 1094 | }, [adapter, docId]); 1095 | return manager; 1096 | }; 1097 | 1098 | // this is the main component that renders the page properties table at the top of the page below 1099 | // the page title 1100 | export const PagePropertiesTable = ({ docId }: { docId: string }) => { 1101 | const manager = usePagePropertiesManager(docId); 1102 | 1103 | // if the given page is not in the current workspace, then we don't render anything 1104 | // eg. when it is in history modal 1105 | 1106 | if (!manager.page) { 1107 | return null; 1108 | } 1109 | 1110 | return ( 1111 | 1112 | 1113 | 1114 | 1115 | 1116 | ); 1117 | }; 1118 | -------------------------------------------------------------------------------- /fixtures/renderer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Comment, 3 | Fragment, 4 | Static, 5 | Text, 6 | type VNode, 7 | type VNodeArrayChildren, 8 | type VNodeHook, 9 | type VNodeProps, 10 | cloneIfMounted, 11 | createVNode, 12 | invokeVNodeHook, 13 | isSameVNodeType, 14 | normalizeVNode, 15 | } from './vnode' 16 | import { 17 | type ComponentInternalInstance, 18 | type ComponentOptions, 19 | type Data, 20 | type LifecycleHook, 21 | createComponentInstance, 22 | setupComponent, 23 | } from './component' 24 | import { 25 | filterSingleRoot, 26 | renderComponentRoot, 27 | shouldUpdateComponent, 28 | updateHOCHostEl, 29 | } from './componentRenderUtils' 30 | import { 31 | EMPTY_ARR, 32 | EMPTY_OBJ, 33 | NOOP, 34 | PatchFlags, 35 | ShapeFlags, 36 | def, 37 | getGlobalThis, 38 | invokeArrayFns, 39 | isArray, 40 | isReservedProp, 41 | } from '@vue/shared' 42 | import { 43 | type SchedulerJob, 44 | SchedulerJobFlags, 45 | type SchedulerJobs, 46 | flushPostFlushCbs, 47 | flushPreFlushCbs, 48 | queueJob, 49 | queuePostFlushCb, 50 | } from './scheduler' 51 | import { 52 | EffectFlags, 53 | ReactiveEffect, 54 | pauseTracking, 55 | resetTracking, 56 | } from '@vue/reactivity' 57 | import { updateProps } from './componentProps' 58 | import { updateSlots } from './componentSlots' 59 | import { popWarningContext, pushWarningContext, warn } from './warning' 60 | import { type CreateAppFunction, createAppAPI } from './apiCreateApp' 61 | import { setRef } from './rendererTemplateRef' 62 | import { 63 | type SuspenseBoundary, 64 | type SuspenseImpl, 65 | isSuspense, 66 | queueEffectWithSuspense, 67 | } from './components/Suspense' 68 | import { 69 | TeleportEndKey, 70 | type TeleportImpl, 71 | type TeleportVNode, 72 | } from './components/Teleport' 73 | import { type KeepAliveContext, isKeepAlive } from './components/KeepAlive' 74 | import { isHmrUpdating, registerHMR, unregisterHMR } from './hmr' 75 | import { type RootHydrateFunction, createHydrationFunctions } from './hydration' 76 | import { invokeDirectiveHook } from './directives' 77 | import { endMeasure, startMeasure } from './profiling' 78 | import { 79 | devtoolsComponentAdded, 80 | devtoolsComponentRemoved, 81 | devtoolsComponentUpdated, 82 | setDevtoolsHook, 83 | } from './devtools' 84 | import { initFeatureFlags } from './featureFlags' 85 | import { isAsyncWrapper } from './apiAsyncComponent' 86 | import { isCompatEnabled } from './compat/compatConfig' 87 | import { DeprecationTypes } from './compat/compatConfig' 88 | import type { TransitionHooks } from './components/BaseTransition' 89 | 90 | export interface Renderer { 91 | render: RootRenderFunction 92 | createApp: CreateAppFunction 93 | } 94 | 95 | export interface HydrationRenderer extends Renderer { 96 | hydrate: RootHydrateFunction 97 | } 98 | 99 | export type ElementNamespace = 'svg' | 'mathml' | undefined 100 | 101 | export type RootRenderFunction = ( 102 | vnode: VNode | null, 103 | container: HostElement, 104 | namespace?: ElementNamespace, 105 | ) => void 106 | 107 | export interface RendererOptions< 108 | HostNode = RendererNode, 109 | HostElement = RendererElement, 110 | > { 111 | patchProp( 112 | el: HostElement, 113 | key: string, 114 | prevValue: any, 115 | nextValue: any, 116 | namespace?: ElementNamespace, 117 | parentComponent?: ComponentInternalInstance | null, 118 | ): void 119 | insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void 120 | remove(el: HostNode): void 121 | createElement( 122 | type: string, 123 | namespace?: ElementNamespace, 124 | isCustomizedBuiltIn?: string, 125 | vnodeProps?: (VNodeProps & { [key: string]: any }) | null, 126 | ): HostElement 127 | createText(text: string): HostNode 128 | createComment(text: string): HostNode 129 | setText(node: HostNode, text: string): void 130 | setElementText(node: HostElement, text: string): void 131 | parentNode(node: HostNode): HostElement | null 132 | nextSibling(node: HostNode): HostNode | null 133 | querySelector?(selector: string): HostElement | null 134 | setScopeId?(el: HostElement, id: string): void 135 | cloneNode?(node: HostNode): HostNode 136 | insertStaticContent?( 137 | content: string, 138 | parent: HostElement, 139 | anchor: HostNode | null, 140 | namespace: ElementNamespace, 141 | start?: HostNode | null, 142 | end?: HostNode | null, 143 | ): [HostNode, HostNode] 144 | } 145 | 146 | // Renderer Node can technically be any object in the context of core renderer 147 | // logic - they are never directly operated on and always passed to the node op 148 | // functions provided via options, so the internal constraint is really just 149 | // a generic object. 150 | export interface RendererNode { 151 | [key: string | symbol]: any 152 | } 153 | 154 | export interface RendererElement extends RendererNode {} 155 | 156 | // An object exposing the internals of a renderer, passed to tree-shakeable 157 | // features so that they can be decoupled from this file. Keys are shortened 158 | // to optimize bundle size. 159 | export interface RendererInternals< 160 | HostNode = RendererNode, 161 | HostElement = RendererElement, 162 | > { 163 | p: PatchFn 164 | um: UnmountFn 165 | r: RemoveFn 166 | m: MoveFn 167 | mt: MountComponentFn 168 | mc: MountChildrenFn 169 | pc: PatchChildrenFn 170 | pbc: PatchBlockChildrenFn 171 | n: NextFn 172 | o: RendererOptions 173 | } 174 | 175 | // These functions are created inside a closure and therefore their types cannot 176 | // be directly exported. In order to avoid maintaining function signatures in 177 | // two places, we declare them once here and use them inside the closure. 178 | type PatchFn = ( 179 | n1: VNode | null, // null means this is a mount 180 | n2: VNode, 181 | container: RendererElement, 182 | anchor?: RendererNode | null, 183 | parentComponent?: ComponentInternalInstance | null, 184 | parentSuspense?: SuspenseBoundary | null, 185 | namespace?: ElementNamespace, 186 | slotScopeIds?: string[] | null, 187 | optimized?: boolean, 188 | ) => void 189 | 190 | type MountChildrenFn = ( 191 | children: VNodeArrayChildren, 192 | container: RendererElement, 193 | anchor: RendererNode | null, 194 | parentComponent: ComponentInternalInstance | null, 195 | parentSuspense: SuspenseBoundary | null, 196 | namespace: ElementNamespace, 197 | slotScopeIds: string[] | null, 198 | optimized: boolean, 199 | start?: number, 200 | ) => void 201 | 202 | type PatchChildrenFn = ( 203 | n1: VNode | null, 204 | n2: VNode, 205 | container: RendererElement, 206 | anchor: RendererNode | null, 207 | parentComponent: ComponentInternalInstance | null, 208 | parentSuspense: SuspenseBoundary | null, 209 | namespace: ElementNamespace, 210 | slotScopeIds: string[] | null, 211 | optimized: boolean, 212 | ) => void 213 | 214 | type PatchBlockChildrenFn = ( 215 | oldChildren: VNode[], 216 | newChildren: VNode[], 217 | fallbackContainer: RendererElement, 218 | parentComponent: ComponentInternalInstance | null, 219 | parentSuspense: SuspenseBoundary | null, 220 | namespace: ElementNamespace, 221 | slotScopeIds: string[] | null, 222 | ) => void 223 | 224 | type MoveFn = ( 225 | vnode: VNode, 226 | container: RendererElement, 227 | anchor: RendererNode | null, 228 | type: MoveType, 229 | parentSuspense?: SuspenseBoundary | null, 230 | ) => void 231 | 232 | type NextFn = (vnode: VNode) => RendererNode | null 233 | 234 | type UnmountFn = ( 235 | vnode: VNode, 236 | parentComponent: ComponentInternalInstance | null, 237 | parentSuspense: SuspenseBoundary | null, 238 | doRemove?: boolean, 239 | optimized?: boolean, 240 | ) => void 241 | 242 | type RemoveFn = (vnode: VNode) => void 243 | 244 | type UnmountChildrenFn = ( 245 | children: VNode[], 246 | parentComponent: ComponentInternalInstance | null, 247 | parentSuspense: SuspenseBoundary | null, 248 | doRemove?: boolean, 249 | optimized?: boolean, 250 | start?: number, 251 | ) => void 252 | 253 | export type MountComponentFn = ( 254 | initialVNode: VNode, 255 | container: RendererElement, 256 | anchor: RendererNode | null, 257 | parentComponent: ComponentInternalInstance | null, 258 | parentSuspense: SuspenseBoundary | null, 259 | namespace: ElementNamespace, 260 | optimized: boolean, 261 | ) => void 262 | 263 | type ProcessTextOrCommentFn = ( 264 | n1: VNode | null, 265 | n2: VNode, 266 | container: RendererElement, 267 | anchor: RendererNode | null, 268 | ) => void 269 | 270 | export type SetupRenderEffectFn = ( 271 | instance: ComponentInternalInstance, 272 | initialVNode: VNode, 273 | container: RendererElement, 274 | anchor: RendererNode | null, 275 | parentSuspense: SuspenseBoundary | null, 276 | namespace: ElementNamespace, 277 | optimized: boolean, 278 | ) => void 279 | 280 | export enum MoveType { 281 | ENTER, 282 | LEAVE, 283 | REORDER, 284 | } 285 | 286 | export const queuePostRenderEffect: ( 287 | fn: SchedulerJobs, 288 | suspense: SuspenseBoundary | null, 289 | ) => void = __FEATURE_SUSPENSE__ 290 | ? __TEST__ 291 | ? // vitest can't seem to handle eager circular dependency 292 | (fn: Function | Function[], suspense: SuspenseBoundary | null) => 293 | queueEffectWithSuspense(fn, suspense) 294 | : queueEffectWithSuspense 295 | : queuePostFlushCb 296 | 297 | /** 298 | * The createRenderer function accepts two generic arguments: 299 | * HostNode and HostElement, corresponding to Node and Element types in the 300 | * host environment. For example, for runtime-dom, HostNode would be the DOM 301 | * `Node` interface and HostElement would be the DOM `Element` interface. 302 | * 303 | * Custom renderers can pass in the platform specific types like this: 304 | * 305 | * ``` js 306 | * const { render, createApp } = createRenderer({ 307 | * patchProp, 308 | * ...nodeOps 309 | * }) 310 | * ``` 311 | */ 312 | export function createRenderer< 313 | HostNode = RendererNode, 314 | HostElement = RendererElement, 315 | >(options: RendererOptions): Renderer { 316 | return baseCreateRenderer(options) 317 | } 318 | 319 | // Separate API for creating hydration-enabled renderer. 320 | // Hydration logic is only used when calling this function, making it 321 | // tree-shakable. 322 | export function createHydrationRenderer( 323 | options: RendererOptions, 324 | ): HydrationRenderer { 325 | return baseCreateRenderer(options, createHydrationFunctions) 326 | } 327 | 328 | // overload 1: no hydration 329 | function baseCreateRenderer< 330 | HostNode = RendererNode, 331 | HostElement = RendererElement, 332 | >(options: RendererOptions): Renderer 333 | 334 | // overload 2: with hydration 335 | function baseCreateRenderer( 336 | options: RendererOptions, 337 | createHydrationFns: typeof createHydrationFunctions, 338 | ): HydrationRenderer 339 | 340 | // implementation 341 | function baseCreateRenderer( 342 | options: RendererOptions, 343 | createHydrationFns?: typeof createHydrationFunctions, 344 | ): any { 345 | // compile-time feature flags check 346 | if (__ESM_BUNDLER__ && !__TEST__) { 347 | initFeatureFlags() 348 | } 349 | 350 | const target = getGlobalThis() 351 | target.__VUE__ = true 352 | if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { 353 | setDevtoolsHook(target.__VUE_DEVTOOLS_GLOBAL_HOOK__, target) 354 | } 355 | 356 | const { 357 | insert: hostInsert, 358 | remove: hostRemove, 359 | patchProp: hostPatchProp, 360 | createElement: hostCreateElement, 361 | createText: hostCreateText, 362 | createComment: hostCreateComment, 363 | setText: hostSetText, 364 | setElementText: hostSetElementText, 365 | parentNode: hostParentNode, 366 | nextSibling: hostNextSibling, 367 | setScopeId: hostSetScopeId = NOOP, 368 | insertStaticContent: hostInsertStaticContent, 369 | } = options 370 | 371 | // Note: functions inside this closure should use `const xxx = () => {}` 372 | // style in order to prevent being inlined by minifiers. 373 | const patch: PatchFn = ( 374 | n1, 375 | n2, 376 | container, 377 | anchor = null, 378 | parentComponent = null, 379 | parentSuspense = null, 380 | namespace = undefined, 381 | slotScopeIds = null, 382 | optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren, 383 | ) => { 384 | if (n1 === n2) { 385 | return 386 | } 387 | 388 | // patching & not same type, unmount old tree 389 | if (n1 && !isSameVNodeType(n1, n2)) { 390 | anchor = getNextHostNode(n1) 391 | unmount(n1, parentComponent, parentSuspense, true) 392 | n1 = null 393 | } 394 | 395 | if (n2.patchFlag === PatchFlags.BAIL) { 396 | optimized = false 397 | n2.dynamicChildren = null 398 | } 399 | 400 | const { type, ref, shapeFlag } = n2 401 | switch (type) { 402 | case Text: 403 | processText(n1, n2, container, anchor) 404 | break 405 | case Comment: 406 | processCommentNode(n1, n2, container, anchor) 407 | break 408 | case Static: 409 | if (n1 == null) { 410 | mountStaticNode(n2, container, anchor, namespace) 411 | } else if (__DEV__) { 412 | patchStaticNode(n1, n2, container, namespace) 413 | } 414 | break 415 | case Fragment: 416 | processFragment( 417 | n1, 418 | n2, 419 | container, 420 | anchor, 421 | parentComponent, 422 | parentSuspense, 423 | namespace, 424 | slotScopeIds, 425 | optimized, 426 | ) 427 | break 428 | default: 429 | if (shapeFlag & ShapeFlags.ELEMENT) { 430 | processElement( 431 | n1, 432 | n2, 433 | container, 434 | anchor, 435 | parentComponent, 436 | parentSuspense, 437 | namespace, 438 | slotScopeIds, 439 | optimized, 440 | ) 441 | } else if (shapeFlag & ShapeFlags.COMPONENT) { 442 | processComponent( 443 | n1, 444 | n2, 445 | container, 446 | anchor, 447 | parentComponent, 448 | parentSuspense, 449 | namespace, 450 | slotScopeIds, 451 | optimized, 452 | ) 453 | } else if (shapeFlag & ShapeFlags.TELEPORT) { 454 | ;(type as typeof TeleportImpl).process( 455 | n1 as TeleportVNode, 456 | n2 as TeleportVNode, 457 | container, 458 | anchor, 459 | parentComponent, 460 | parentSuspense, 461 | namespace, 462 | slotScopeIds, 463 | optimized, 464 | internals, 465 | ) 466 | } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { 467 | ;(type as typeof SuspenseImpl).process( 468 | n1, 469 | n2, 470 | container, 471 | anchor, 472 | parentComponent, 473 | parentSuspense, 474 | namespace, 475 | slotScopeIds, 476 | optimized, 477 | internals, 478 | ) 479 | } else if (__DEV__) { 480 | warn('Invalid VNode type:', type, `(${typeof type})`) 481 | } 482 | } 483 | 484 | // set ref 485 | if (ref != null && parentComponent) { 486 | setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2) 487 | } 488 | } 489 | 490 | const processText: ProcessTextOrCommentFn = (n1, n2, container, anchor) => { 491 | if (n1 == null) { 492 | hostInsert( 493 | (n2.el = hostCreateText(n2.children as string)), 494 | container, 495 | anchor, 496 | ) 497 | } else { 498 | const el = (n2.el = n1.el!) 499 | if (n2.children !== n1.children) { 500 | hostSetText(el, n2.children as string) 501 | } 502 | } 503 | } 504 | 505 | const processCommentNode: ProcessTextOrCommentFn = ( 506 | n1, 507 | n2, 508 | container, 509 | anchor, 510 | ) => { 511 | if (n1 == null) { 512 | hostInsert( 513 | (n2.el = hostCreateComment((n2.children as string) || '')), 514 | container, 515 | anchor, 516 | ) 517 | } else { 518 | // there's no support for dynamic comments 519 | n2.el = n1.el 520 | } 521 | } 522 | 523 | const mountStaticNode = ( 524 | n2: VNode, 525 | container: RendererElement, 526 | anchor: RendererNode | null, 527 | namespace: ElementNamespace, 528 | ) => { 529 | // static nodes are only present when used with compiler-dom/runtime-dom 530 | // which guarantees presence of hostInsertStaticContent. 531 | ;[n2.el, n2.anchor] = hostInsertStaticContent!( 532 | n2.children as string, 533 | container, 534 | anchor, 535 | namespace, 536 | n2.el, 537 | n2.anchor, 538 | ) 539 | } 540 | 541 | /** 542 | * Dev / HMR only 543 | */ 544 | const patchStaticNode = ( 545 | n1: VNode, 546 | n2: VNode, 547 | container: RendererElement, 548 | namespace: ElementNamespace, 549 | ) => { 550 | // static nodes are only patched during dev for HMR 551 | if (n2.children !== n1.children) { 552 | const anchor = hostNextSibling(n1.anchor!) 553 | // remove existing 554 | removeStaticNode(n1) 555 | // insert new 556 | ;[n2.el, n2.anchor] = hostInsertStaticContent!( 557 | n2.children as string, 558 | container, 559 | anchor, 560 | namespace, 561 | ) 562 | } else { 563 | n2.el = n1.el 564 | n2.anchor = n1.anchor 565 | } 566 | } 567 | 568 | const moveStaticNode = ( 569 | { el, anchor }: VNode, 570 | container: RendererElement, 571 | nextSibling: RendererNode | null, 572 | ) => { 573 | let next 574 | while (el && el !== anchor) { 575 | next = hostNextSibling(el) 576 | hostInsert(el, container, nextSibling) 577 | el = next 578 | } 579 | hostInsert(anchor!, container, nextSibling) 580 | } 581 | 582 | const removeStaticNode = ({ el, anchor }: VNode) => { 583 | let next 584 | while (el && el !== anchor) { 585 | next = hostNextSibling(el) 586 | hostRemove(el) 587 | el = next 588 | } 589 | hostRemove(anchor!) 590 | } 591 | 592 | const processElement = ( 593 | n1: VNode | null, 594 | n2: VNode, 595 | container: RendererElement, 596 | anchor: RendererNode | null, 597 | parentComponent: ComponentInternalInstance | null, 598 | parentSuspense: SuspenseBoundary | null, 599 | namespace: ElementNamespace, 600 | slotScopeIds: string[] | null, 601 | optimized: boolean, 602 | ) => { 603 | if (n2.type === 'svg') { 604 | namespace = 'svg' 605 | } else if (n2.type === 'math') { 606 | namespace = 'mathml' 607 | } 608 | 609 | if (n1 == null) { 610 | mountElement( 611 | n2, 612 | container, 613 | anchor, 614 | parentComponent, 615 | parentSuspense, 616 | namespace, 617 | slotScopeIds, 618 | optimized, 619 | ) 620 | } else { 621 | patchElement( 622 | n1, 623 | n2, 624 | parentComponent, 625 | parentSuspense, 626 | namespace, 627 | slotScopeIds, 628 | optimized, 629 | ) 630 | } 631 | } 632 | 633 | const mountElement = ( 634 | vnode: VNode, 635 | container: RendererElement, 636 | anchor: RendererNode | null, 637 | parentComponent: ComponentInternalInstance | null, 638 | parentSuspense: SuspenseBoundary | null, 639 | namespace: ElementNamespace, 640 | slotScopeIds: string[] | null, 641 | optimized: boolean, 642 | ) => { 643 | let el: RendererElement 644 | let vnodeHook: VNodeHook | undefined | null 645 | const { props, shapeFlag, transition, dirs } = vnode 646 | 647 | el = vnode.el = hostCreateElement( 648 | vnode.type as string, 649 | namespace, 650 | props && props.is, 651 | props, 652 | ) 653 | 654 | // mount children first, since some props may rely on child content 655 | // being already rendered, e.g. `