From da558fd52d3974b502730475e0715a2d08266e13 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 13 Jul 2026 06:19:26 -0700 Subject: [PATCH] fix(builder): render template header/footer nested content in zone preview + export TemplateModal's addTemplateComponents() built each template component via React.createElement(Component, comp.props) without ever passing comp.children, silently dropping every nested children array authored in templates/definitions.ts (header/footer Container > Logo/Menu/TextBlock, page Section > Heading/TextBlock/ButtonLink). The resulting Craft.js node had nodes: [], so the header/footer zone preview (ZonePreview -> exportBodyHtml) rendered as an empty strip, and published output was affected the same way. Fix converts each TemplateComponent to a SerializedTreeNode and reuses craft-tree.ts's buildNodeTree (sanitize -> flatten -> materialize) -- the same tested tree pipeline already used for AI-generated content -- instead of hand-rolling a React-element tree, since a naive nested-children fix via parseReactElement crashes any component with an internal SHELL_INNER linked canvas (Section/BackgroundSection/FormContainer) or linked columns (ColumnLayout). Also fixes two latent bugs in buildNodeTree itself, only surfaced by exercising it against a real Craft.js editor for the first time: data.type must be the actual resolved component reference (not a string or {resolvedName} object) for correct rendering, and the synthesized SHELL_INNER node needs data.name set for actions.addNodeTree's own validation. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/panels/topbar/TemplateModal.tsx | 32 ++-- .../topbar/template-zone-export.test.tsx | 162 ++++++++++++++++++ craft/src/templates/apply-template.test.ts | 51 ++++++ craft/src/templates/apply-template.ts | 49 ++++++ craft/src/utils/apply-ai-response.ts | 60 +------ craft/src/utils/craft-tree.ts | 99 +++++++++++ 6 files changed, 386 insertions(+), 67 deletions(-) create mode 100644 craft/src/panels/topbar/template-zone-export.test.tsx create mode 100644 craft/src/templates/apply-template.test.ts create mode 100644 craft/src/templates/apply-template.ts diff --git a/craft/src/panels/topbar/TemplateModal.tsx b/craft/src/panels/topbar/TemplateModal.tsx index cf7ee49..11a27f3 100644 --- a/craft/src/panels/topbar/TemplateModal.tsx +++ b/craft/src/panels/topbar/TemplateModal.tsx @@ -8,9 +8,10 @@ import { TemplateComponent, TemplateCategory, } from '../../templates'; -import { componentResolver } from '../../components/resolver'; import { clickableProps } from '../../utils/a11y'; import { Modal } from '../../ui/Modal'; +import { buildNodeTree } from '../../utils/craft-tree'; +import { templateComponentToTreeNode } from '../../templates/apply-template'; // --------------------------------------------------------------------------- // Types @@ -77,28 +78,31 @@ export const TemplateModal: React.FC = ({ open, onClose }) = return allTemplates.filter((t) => t.category === activeTab); }, [activeTab]); - // Resolve a TemplateComponent type name to its React component - const resolverMap = componentResolver as Record>; - /** * Add all components from a template definition onto the current (empty) canvas ROOT. - * Uses Craft.js parseReactElement + addNodeTree which correctly builds valid node structures. + * Converts each `TemplateComponent` (the plain `{type, props, children?}` + * shape templates author) to a `SerializedTreeNode` and runs it through + * `buildNodeTree` (sanitize -> flatten -> materialize), the same tree pipeline + * already used for AI-generated content. This recurses into nested + * `children` (e.g. a header/footer `Container` wrapping `Logo`/`Menu`, or a + * page `Section` wrapping a `Heading`) AND correctly routes them through a + * component's SHELL_INNER linked canvas / linked columns where needed -- + * see `templates/apply-template.ts` for the two bugs this fixes (dropped + * children, and a naive `parseReactElement` tree crashing `Section`/ + * `BackgroundSection`/`FormContainer`/`ColumnLayout`). */ const addTemplateComponents = useCallback( (components: TemplateComponent[]) => { for (const comp of components) { - const Component = resolverMap[comp.type]; - if (!Component) { - console.warn(`Template references unknown component type: ${comp.type}`); - continue; + try { + const tree = buildNodeTree(query, templateComponentToTreeNode(comp)); + actions.addNodeTree(tree, 'ROOT'); + } catch (e) { + console.warn(`Failed to build template component tree for type "${comp.type}":`, e); } - - const element = React.createElement(Component, comp.props); - const tree = query.parseReactElement(element).toNodeTree(); - actions.addNodeTree(tree, 'ROOT'); } }, - [query, actions, resolverMap], + [query, actions], ); /** diff --git a/craft/src/panels/topbar/template-zone-export.test.tsx b/craft/src/panels/topbar/template-zone-export.test.tsx new file mode 100644 index 0000000..9769956 --- /dev/null +++ b/craft/src/panels/topbar/template-zone-export.test.tsx @@ -0,0 +1,162 @@ +import { describe, test, expect } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { Editor, useEditor } from '@craftjs/core'; +import { componentResolver } from '../../components/resolver'; +import { allTemplates } from '../../templates'; +import { buildNodeTree } from '../../utils/craft-tree'; +import { templateComponentToTreeNode } from '../../templates/apply-template'; +import { exportBodyHtml } from '../../utils/html-export'; +import { DEFAULT_HEADER_STATE } from '../../state/PageContext'; + +/** + * Regression test for: loading a template makes the header/footer ZONE + * PREVIEW (`ZonePreview` in `editor/Canvas.tsx`, which calls + * `exportBodyHtml(craftState)`) render as an empty strip -- no nav links, no + * logo, no footer text -- and (a second bug found while fixing the first) + * makes a template page's `Section`-wrapped content crash the live editor + * entirely. + * + * This drives the EXACT same path `TemplateModal.tsx`'s `applyZone` / + * `addTemplateComponents` uses to load a template's header/footer/page + * content onto the live Craft.js canvas (`buildNodeTree` + + * `actions.addNodeTree(tree, 'ROOT')`), then serializes it + * (`query.serialize()`, exactly what `PageContext`'s `switchPage` / + * `saveCurrentState` do right after `TemplateModal` finishes applying a + * zone/page) and feeds the result through `exportBodyHtml` -- the same + * function `ZonePreview` calls, and the same function `handlePublish` uses + * to compose the published header/footer HTML. So this also covers whether + * published output is affected (it was). + */ +let api: any; +function Harness() { + const { actions, query } = useEditor(); + api = { actions, query }; + return null; +} + +function mount() { + const container = document.createElement('div'); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render( + + + , + ); + }); + return { + container, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +/** Mirrors TemplateModal.tsx's addTemplateComponents(). */ +function addTemplateComponents(components: any[]) { + for (const comp of components) { + const tree = buildNodeTree(api.query, templateComponentToTreeNode(comp)); + api.actions.addNodeTree(tree, 'ROOT'); + } +} + +const EMPTY_HEADER_ROOT = + '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{},"tag":"header"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}'; + +const EMPTY_CANVAS_ROOT = + '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}'; + +describe('template header/footer zone export (loading a template)', () => { + test('sanity check: the DEFAULT header (fresh project, no template) exports its content', () => { + const { html } = exportBodyHtml(DEFAULT_HEADER_STATE); + expect(html).toContain('MySite'); + }); + + test('a template header (SaaS Landing) exports its real nav/logo content -- not an empty strip', () => { + const tpl = allTemplates.find((t) => t.id === 'saas-landing'); + expect(tpl).toBeDefined(); + expect(tpl!.header.components.length).toBeGreaterThan(0); + + const { unmount } = mount(); + act(() => { + api.actions.deserialize(EMPTY_HEADER_ROOT); + }); + act(() => { + addTemplateComponents(tpl!.header.components); + }); + + const serialized = api.query.serialize(); + const { html } = exportBodyHtml(serialized); + + // The template's logo text and a real nav link -- if these are missing, + // the header rendered as an empty strip (the reported bug). + expect(html).toContain('FlowStack'); + expect(html).toContain('Features'); + expect(html).toContain('Start Free'); + + unmount(); + }); + + test('a template footer (SaaS Landing) exports its real copyright/tagline content -- not an empty strip', () => { + const tpl = allTemplates.find((t) => t.id === 'saas-landing'); + expect(tpl).toBeDefined(); + expect(tpl!.footer.components.length).toBeGreaterThan(0); + + const { unmount } = mount(); + act(() => { + api.actions.deserialize( + '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}', + ); + }); + act(() => { + addTemplateComponents(tpl!.footer.components); + }); + + const serialized = api.query.serialize(); + const { html } = exportBodyHtml(serialized); + + expect(html).toContain('FlowStack, Inc.'); + expect(html).toContain('Ship products faster'); + + unmount(); + }); + + test('a template page Section with nested children (SaaS Landing Home) builds + exports without crashing', () => { + // Regression for the SECOND bug surfaced while fixing the first: naively + // rebuilding nested children via React.createElement + parseReactElement + // crashes any component with an internal SHELL_INNER linked canvas + // (Section/BackgroundSection/FormContainer) or linked columns + // (ColumnLayout). The SaaS Landing home page's second component is a + // `Section` wrapping a `Heading` ("Trusted by 10,000+ development + // teams...") -- exactly this shape. + const tpl = allTemplates.find((t) => t.id === 'saas-landing'); + const homePage = tpl!.pages[0]; + const sectionComp = homePage.content.components.find((c) => c.type === 'Section'); + expect(sectionComp).toBeDefined(); + expect(sectionComp!.children?.length).toBeGreaterThan(0); + + const { unmount } = mount(); + act(() => { + api.actions.deserialize(EMPTY_CANVAS_ROOT); + }); + + expect(() => { + act(() => { + addTemplateComponents([sectionComp!]); + }); + }).not.toThrow(); + + const serialized = api.query.serialize(); + const { html } = exportBodyHtml(serialized); + expect(html).toContain('Trusted by 10,000+ development teams'); + + unmount(); + }); +}); diff --git a/craft/src/templates/apply-template.test.ts b/craft/src/templates/apply-template.test.ts new file mode 100644 index 0000000..079c108 --- /dev/null +++ b/craft/src/templates/apply-template.test.ts @@ -0,0 +1,51 @@ +import { describe, test, expect } from 'vitest'; +import { templateComponentToTreeNode } from './apply-template'; + +describe('templateComponentToTreeNode', () => { + test('converts a childless TemplateComponent to a SerializedTreeNode with an empty `nodes` array', () => { + const node = templateComponentToTreeNode({ type: 'Heading', props: { text: 'Hi' } }); + expect(node).toEqual({ + type: { resolvedName: 'Heading' }, + props: { text: 'Hi' }, + nodes: [], + }); + }); + + test('regression: recursively converts nested `children` into `nodes` (previously dropped entirely)', () => { + const node = templateComponentToTreeNode({ + type: 'Container', + props: { tag: 'header' }, + children: [ + { type: 'Logo', props: { text: 'FlowStack' } }, + { type: 'Menu', props: { links: [] } }, + ], + }); + expect(node.nodes).toHaveLength(2); + expect(node.nodes![0]).toEqual({ type: { resolvedName: 'Logo' }, props: { text: 'FlowStack' }, nodes: [] }); + expect(node.nodes![1]).toEqual({ type: { resolvedName: 'Menu' }, props: { links: [] }, nodes: [] }); + }); + + test('recurses more than one level deep', () => { + const node = templateComponentToTreeNode({ + type: 'Section', + props: {}, + children: [ + { + type: 'ColumnLayout', + props: { columns: 1 }, + children: [{ type: 'TextBlock', props: { text: 'deep' } }], + }, + ], + }); + const col = node.nodes![0]; + expect(col.type.resolvedName).toBe('ColumnLayout'); + expect(col.nodes![0]).toEqual({ type: { resolvedName: 'TextBlock' }, props: { text: 'deep' }, nodes: [] }); + }); + + test('does not mutate the input TemplateComponent', () => { + const comp = { type: 'Heading', props: { text: 'Hi' } }; + const frozen = JSON.parse(JSON.stringify(comp)); + templateComponentToTreeNode(comp); + expect(comp).toEqual(frozen); + }); +}); diff --git a/craft/src/templates/apply-template.ts b/craft/src/templates/apply-template.ts new file mode 100644 index 0000000..de48423 --- /dev/null +++ b/craft/src/templates/apply-template.ts @@ -0,0 +1,49 @@ +import type { SerializedTreeNode } from '../types/sitesmith'; +import { TemplateComponent } from './definitions'; + +/** + * Converts a template's `TemplateComponent` tree (the plain + * `{ type, props, children? }` shape authored in `templates/definitions.ts`) + * into a `SerializedTreeNode` (the shape `craft-tree.ts`'s `buildNodeTree` / + * `sanitizeAiTree` / `flattenTreeForCraft` pipeline already knows how to + * materialize into a real Craft.js `NodeTree`). + * + * Bug this fixes: `TemplateModal`'s `addTemplateComponents` used to call + * `React.createElement(Component, comp.props)` with no children argument, + * which silently dropped every nested `children` array authored in + * definitions.ts -- e.g. `makeHeader()`'s `Container > Logo + Menu`, + * `makeFooterContent()`'s `Container > TextBlock...`, or a page `Section` + * wrapping a `Heading`/`TextBlock`/`ButtonLink`. The resulting Craft.js node + * ended up with `nodes: []`, so the header/footer zone preview + * (`ZonePreview` in `editor/Canvas.tsx`, via `exportBodyHtml`) -- and the + * same-path published HTML (`handlePublish` composes header/footer HTML + * from the same serialized state) -- rendered as an empty strip instead of + * the real nav/footer/section content. + * + * A follow-up attempt fixed that by building a real nested React element + * tree (`React.createElement(Component, props, ...children)`) and handing + * it to `query.parseReactElement(...).toNodeTree()`. That approach breaks + * for any component with an internal SHELL_INNER-style linked canvas + * (`Section`, `BackgroundSection`, `FormContainer` -- see `SHELL_INNER` in + * `craft-tree.ts`) or linked columns (`ColumnLayout`): `parseReactElement` + * statically walks the JSX tree and has no way to know that e.g. `Section` + * routes its `children` into a nested `` + * at render time, not directly under itself. Feeding it raw nested children + * produces a tree where Craft.js has to auto-materialize that linked node on + * first render -- which stores the wrong `type` and crashes with "component + * type (undefined) does not exist in the resolver" the moment the section + * renders (reproduced live: loading the SaaS Landing template crashed the + * whole editor on its "Trusted by 10,000+ development teams" `Section`). + * + * Converting to `SerializedTreeNode` and reusing `buildNodeTree` sidesteps + * both bugs at once: it's the same tree-building pipeline already used (and + * tested) for AI-generated content in `apply-ai-response.ts`, which already + * knows how to route content through SHELL_INNER / linked columns correctly. + */ +export function templateComponentToTreeNode(comp: TemplateComponent): SerializedTreeNode { + return { + type: { resolvedName: comp.type }, + props: { ...comp.props }, + nodes: (comp.children || []).map(templateComponentToTreeNode), + }; +} diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 0891372..1b2b555 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -1,14 +1,14 @@ import { useEditor } from '@craftjs/core'; -import type { NodeTree } from '@craftjs/core'; import { usePages } from '../state/PageContext'; import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith'; -import { sanitizeAiTree, flattenTreeForCraft } from './craft-tree'; +import { sanitizeAiTree, buildNodeTree } from './craft-tree'; -// Re-exported for existing callers/tests that import sanitizeAiTree from -// this module — the implementation now lives in craft-tree.ts alongside the -// shared flattener, since treeToState (PageContext.tsx) needs the same -// validation this module pioneered. -export { sanitizeAiTree }; +// Re-exported for existing callers/tests that import sanitizeAiTree/ +// buildNodeTree from this module — the implementation lives in craft-tree.ts +// alongside the shared flattener: `templates/apply-template.ts` needs the +// exact same tree-materialization logic to load template header/footer/page +// content (see the doc comment on `buildNodeTree` in craft-tree.ts). +export { sanitizeAiTree, buildNodeTree }; /** * update_props may never touch these keys, regardless of what the AI sends. @@ -18,52 +18,6 @@ export { sanitizeAiTree }; */ const PROTECTED_UPDATE_PROPS_KEYS = new Set(['node_id']); -/** - * Build a Craft.js `NodeTree` from a `SerializedTreeNode` using `query.parseFreshNode`. - * This is the correct way to construct a tree for `actions.addNodeTree()` when - * inserting/replacing sections or individual nodes. - */ -function buildNodeTree(query: any, tree: SerializedTreeNode): NodeTree { - // Validate + sanitize at the AI boundary before touching Craft.js at all: - // unknown resolvedNames are dropped (never reach parseFreshNode/addNodeTree, - // which would throw), and ids are repaired ('ROOT'/empty/colliding → - // regenerated) against the live document's existing node ids so a new - // AI-supplied id can never clobber or duplicate one already on the canvas. - const existingIds = new Set( - typeof query?.getNodes === 'function' ? Object.keys(query.getNodes()) : [], - ); - const sanitized = sanitizeAiTree(tree, existingIds); - if (!sanitized) { - throw new Error('sitesmith: AI tree root has an unknown/invalid component type; nothing to build'); - } - - // sanitizeAiTree() has already guaranteed every node has a valid, unique, - // non-'ROOT' node_id and a registered resolvedName — the shared flattener - // just needs to do the structural walk (linkedNodes/SHELL_INNER/style - // normalization) common to every tree-flattening caller in this codebase. - const flat = flattenTreeForCraft(sanitized); - - const craftNodes: Record = {}; - for (const [id, flatNode] of Object.entries(flat.nodes)) { - if (flat.syntheticIds.has(id)) { - // The SHELL_INNER wrapper Container is synthesized by the flattener, - // not present in the AI's tree — build it directly rather than - // through query.parseFreshNode(), which would merge in Container's - // default `craft.props` (e.g. default padding) and change its look. - craftNodes[id] = { - id, - data: { ...flatNode }, - events: { selected: false, hovered: false, dragged: false }, - rules: { canDrag: () => true, canMoveIn: () => true, canMoveOut: () => true, canDrop: () => true }, - }; - } else { - craftNodes[id] = (query.parseFreshNode({ id, data: { ...flatNode } }) as any).toNode(); - } - } - - return { rootNodeId: flat.rootNodeId, nodes: craftNodes }; -} - /** * Find the Craft.js node id that corresponds to an AI node_id value. * Checks `data.props.node_id` first, then falls back to raw id equality. diff --git a/craft/src/utils/craft-tree.ts b/craft/src/utils/craft-tree.ts index 40a0cd8..e201920 100644 --- a/craft/src/utils/craft-tree.ts +++ b/craft/src/utils/craft-tree.ts @@ -225,6 +225,105 @@ export function flattenTreeForCraft(tree: SerializedTreeNode): FlattenTreeResult return { rootNodeId, nodes, syntheticIds }; } +/** + * Build a Craft.js `NodeTree` from a `SerializedTreeNode` using + * `query.parseFreshNode`. This is the correct way to construct a tree for + * `actions.addNodeTree()` when inserting/replacing sections, individual + * nodes, or (via `templates/apply-template.ts`) a template's header/footer/ + * page content -- anything whose input isn't already a live Craft.js state. + * + * Originally lived in `apply-ai-response.ts` (the AI-response path pioneered + * it); moved here alongside `sanitizeAiTree`/`flattenTreeForCraft` once + * `templates/apply-template.ts` needed the exact same tree-materialization + * logic — building a node tree by hand via `React.createElement` + + * `query.parseReactElement` does NOT correctly route content through a + * `Section`/`BackgroundSection`/`FormContainer`'s internal SHELL_INNER + * linked canvas or `ColumnLayout`'s linked columns (see the `SHELL_INNER` + * comment above): Craft.js would auto-materialize that linked node at + * render time with a botched `type` field and crash `toNodeTree` with + * "component type (undefined) does not exist in the resolver". Going through + * `sanitizeAiTree` + `flattenTreeForCraft` (as this function does) avoids + * that entirely. + */ +export function buildNodeTree(query: any, tree: SerializedTreeNode): NodeTree { + // Validate + sanitize at the boundary before touching Craft.js at all: + // unknown resolvedNames are dropped (never reach parseFreshNode/addNodeTree, + // which would throw), and ids are repaired ('ROOT'/empty/colliding → + // regenerated) against the live document's existing node ids so a new + // caller-supplied id can never clobber or duplicate one already on the canvas. + const existingIds = new Set( + typeof query?.getNodes === 'function' ? Object.keys(query.getNodes()) : [], + ); + const sanitized = sanitizeAiTree(tree, existingIds); + if (!sanitized) { + throw new Error('craft-tree: tree root has an unknown/invalid component type; nothing to build'); + } + + // sanitizeAiTree() has already guaranteed every node has a valid, unique, + // non-'ROOT' node_id and a registered resolvedName — flattenTreeForCraft + // just needs to do the structural walk (linkedNodes/SHELL_INNER/style + // normalization) common to every tree-flattening caller in this codebase. + const flat = flattenTreeForCraft(sanitized); + + const craftNodes: Record = {}; + for (const [id, flatNode] of Object.entries(flat.nodes)) { + // Live Craft.js nodes need `data.type` to be the ACTUAL component + // reference (the function/class from `componentResolver`) -- not the + // `{ resolvedName }` wrapper object `flattenTreeForCraft` produces (that + // wrapper is the *serialized* form used by `actions.deserialize()`/ + // `query.serialize()`, a different Craft.js entry point), and not a + // plain resolvedName string either. + // + // A plain string passes `parseFreshNode`'s own validation (it special- + // cases strings) AND `actions.addNodeTree`'s, so that alone looks fine -- + // but it's wrong for RENDERING: Craft.js's node renderer does + // `React.createElement(data.type, props)` directly, so a string type + // makes React treat e.g. "Section" as a literal (invalid, lowercase- + // expected) HTML tag name instead of resolving it to the real `Section` + // component. Symptom: "is using incorrect casing" console warnings and + // the component's own render logic (and thus its content) never runs -- + // reproduced live by loading a template: the header/footer rendered fine + // but the page canvas went blank. The `{ resolvedName }` wrapper object + // fails even earlier, at `parseFreshNode`/`addNodeTree`'s own validation + // ("component type (undefined) does not exist in the resolver"), because + // their resolution only accepts a string or a component reference. + // + // None of this was caught by the pre-existing mocked test for this + // function (in apply-ai-response.test.ts) because its fake + // `parseFreshNode` just echoes the input back unchanged rather than + // exercising Craft.js's real validation/rendering. + const resolvedComponent = (componentResolver as Record)[flatNode.type.resolvedName]; + + if (flat.syntheticIds.has(id)) { + // The SHELL_INNER wrapper Container is synthesized by the flattener, + // not present in the input tree — build it directly rather than + // through query.parseFreshNode(), which would merge in Container's + // default `craft.props` (e.g. default padding) and change its look. + // + // `actions.addNodeTree`'s own validation for a non-string `data.type` + // checks `resolver[data.name]` (NOT a reverse lookup from `data.type` + // like `parseFreshNode` does) -- so `data.name` must be set to the + // resolvedName string here too, or this synthetic node fails that + // check with "component type (Container) does not exist in the + // resolver" the moment `addNodeTree` runs, even though `resolvedComponent` + // itself is perfectly valid. + craftNodes[id] = { + id, + data: { ...flatNode, type: resolvedComponent, name: flatNode.type.resolvedName }, + events: { selected: false, hovered: false, dragged: false }, + rules: { canDrag: () => true, canMoveIn: () => true, canMoveOut: () => true, canDrop: () => true }, + }; + } else { + craftNodes[id] = (query.parseFreshNode({ + id, + data: { ...flatNode, type: resolvedComponent }, + }) as any).toNode(); + } + } + + return { rootNodeId: flat.rootNodeId, nodes: craftNodes }; +} + /** * Returns a deep-cloned NodeTree where every node id in the tree * (rootNodeId, the `nodes` map keys, each `node.id`, each internal -- 2.52.0