From 27182d6bf17ef539a14045a40f85a7bd836eaeb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 03:53:46 +0300 Subject: [PATCH 1/3] feat(vite-plugin): enable clone optimization for components with child instances Remove the componentInstances.size === 0 guard so components with child component instances can use the clone optimization path. The compiler emits a data-gea-child-slot placeholder in the static HTML skeleton and generates parentNode.replaceChild(this.child.el, placeholder) calls in __cloneTemplate to wire up child elements at mount time. Co-Authored-By: Claude Sonnet 4.6 --- .changeset/partial-clone-optimization.md | 7 + .../vite-plugin-gea/src/codegen/gen-clone.ts | 87 +++++++++- .../vite-plugin-gea/src/codegen/generator.ts | 1 - .../tests/optimization-tests.test.ts | 156 ++++++++++++++++++ 4 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 .changeset/partial-clone-optimization.md diff --git a/.changeset/partial-clone-optimization.md b/.changeset/partial-clone-optimization.md new file mode 100644 index 00000000..eba067d2 --- /dev/null +++ b/.changeset/partial-clone-optimization.md @@ -0,0 +1,7 @@ +--- +"@geajs/vite-plugin": patch +--- + +### @geajs/vite-plugin (patch) + +- **Partial clone optimization for child components**: Components containing child component instances now use the clone optimization path. The compiler emits a placeholder element (`data-gea-child-slot`) in the static HTML template and generates `replaceChild` calls in `__cloneTemplate` to swap each placeholder with the child component's `el` at mount time. diff --git a/packages/vite-plugin-gea/src/codegen/gen-clone.ts b/packages/vite-plugin-gea/src/codegen/gen-clone.ts index 0c637915..e7139c92 100644 --- a/packages/vite-plugin-gea/src/codegen/gen-clone.ts +++ b/packages/vite-plugin-gea/src/codegen/gen-clone.ts @@ -24,6 +24,11 @@ import { EVENT_TYPES, VOID_ELEMENTS } from '../ir/constants.ts' import { emitMount } from '../emit/registry.ts' import { id, js, jsClassProp, jsExpr, jsMethod, tpl } from 'eszter' +/** HTML parent tags where it is safe to insert an arbitrary placeholder child element. */ +const SAFE_CLONE_PARENTS = new Set([ + 'div', 'span', 'section', 'article', 'header', 'footer', 'main', 'nav', 'figure', +]) + // ─── Types ───────────────────────────────────────────────────────── export type CloneContentPatch = { @@ -38,6 +43,11 @@ export type CloneIdentityPatch = | { kind: 'dataGeaEvent'; childPath: number[]; token: string } | { kind: 'attr'; childPath: number[]; expr: t.Expression; attrName: string } +export type CloneComponentSlotPatch = { + childPath: number[] + instanceVar: string +} + // ─── Event ID expression builder ────────────────────────────────── function buildEventIdExpr(suffix?: string): t.Expression { @@ -56,9 +66,13 @@ export function jsxToStaticHtml( refCounter: { value: number }, elementPath: string[] = [], _isRoot = true, + parentTag?: string, ): string | null { const tagName = getJSXTagName(node.openingElement.name) - if (tagName && isComponentTag(tagName)) return null + if (tagName && isComponentTag(tagName)) { + if (!parentTag || !SAFE_CLONE_PARENTS.has(parentTag)) return null + return `<${parentTag} data-gea-child-slot>` + } const effectiveTag = tagName! let html = `<${effectiveTag}` @@ -105,7 +119,7 @@ export function jsxToStaticHtml( } html += '>' - const childHtml = processStaticChildren(node.children, refCounter, elementPath) + const childHtml = processStaticChildren(node.children, refCounter, elementPath, undefined, undefined, effectiveTag) if (childHtml === null) return null return html + childHtml + `` } @@ -118,6 +132,7 @@ function processStaticChildren( parentPath: string[], dcCursor?: { index: number }, directChildren?: ReturnType, + parentTag?: string, ): string | null { const cursor = dcCursor ?? { index: 0 } const dc = directChildren ?? getDirectChildElements(children as any) @@ -130,11 +145,11 @@ function processStaticChildren( const seg = dc[cursor.index]?.selectorSegment cursor.index++ const nextPath = seg ? [...parentPath, seg] : parentPath - const inner = jsxToStaticHtml(child, refCounter, nextPath, false) + const inner = jsxToStaticHtml(child, refCounter, nextPath, false, parentTag) if (inner === null) return null out += inner } else if (t.isJSXFragment(child)) { - const inner = processStaticChildren(child.children, refCounter, parentPath, cursor, dc) + const inner = processStaticChildren(child.children, refCounter, parentPath, cursor, dc, parentTag) if (inner === null) return null out += inner } else if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)) { @@ -240,8 +255,8 @@ export function collectClonePatchEntries( const flattened = getDirectChildElements(node.children as any) flattened.forEach((dc, idx) => { const tag = getJSXTagName(dc.node.openingElement.name) - const isCompChild = Boolean(tag && isComponentTag(tag)) - collectClonePatchEntries(dc.node, [...path, idx], entries, isCompChild) + if (tag && isComponentTag(tag)) return + collectClonePatchEntries(dc.node, [...path, idx], entries, false) }) } @@ -468,6 +483,36 @@ function collectIdentityPatchesForElement( }) } +// ─── Collect component slot patches ─────────────────────────────── + +function collectComponentSlotPatches( + node: t.JSXElement, + path: number[], + slots: CloneComponentSlotPatch[], + componentInstances: Map, + instanceCursors: Map, + consumeOnly = false, +): void { + const flattened = getDirectChildElements(node.children as any) + flattened.forEach((dc, idx) => { + const tag = getJSXTagName(dc.node.openingElement.name) + if (tag && isComponentTag(tag)) { + const instances = componentInstances.get(tag) + const cursor = instanceCursors.get(tag) ?? 0 + const instance = instances?.[cursor] + if (instance) { + instanceCursors.set(tag, cursor + 1) + if (!consumeOnly) { + slots.push({ childPath: [...path, idx], instanceVar: instance.instanceVar }) + } + } + collectComponentSlotPatches(dc.node, [...path, idx], slots, componentInstances, instanceCursors, true) + } else { + collectComponentSlotPatches(dc.node, [...path, idx], slots, componentInstances, instanceCursors, consumeOnly) + } + }) +} + // ─── Generate clone members ──────────────────────────────────────── export function generateCloneMembers( @@ -520,7 +565,12 @@ export function generateCloneMembers( return t; })()` - const cloneMethodBody = buildCloneTemplateBody(identityPatches, contentPatches, cloneCtx) + const componentSlotPatches: CloneComponentSlotPatch[] = [] + if (cloneCtx.componentInstances && cloneCtx.componentInstances.size > 0) { + collectComponentSlotPatches(root, [], componentSlotPatches, cloneCtx.componentInstances, new Map()) + } + + const cloneMethodBody = buildCloneTemplateBody(identityPatches, contentPatches, componentSlotPatches, cloneCtx) const cloneMethod = jsMethod`[${id('GEA_CLONE_TEMPLATE')}]() {}` cloneMethod.body.body.push(...cloneMethodBody) @@ -532,6 +582,7 @@ export function generateCloneMembers( function buildCloneTemplateBody( identityPatches: CloneIdentityPatch[], contentPatches: CloneContentPatch[], + componentSlotPatches: CloneComponentSlotPatch[], cloneCtx: Ctx, ): t.Statement[] { const rootVar = id('__root') @@ -545,6 +596,7 @@ function buildCloneTemplateBody( const allChildPaths = new Set() for (const p of identityPatches) allChildPaths.add(p.childPath.join('_')) for (const p of contentPatches) allChildPaths.add(p.childPath.join('_')) + for (const p of componentSlotPatches) allChildPaths.add(p.childPath.join('_')) for (const key of allChildPaths) { if (!key) continue const path = key.split('_').map((n) => parseInt(n, 10)) @@ -580,6 +632,27 @@ function buildCloneTemplateBody( stmts.push(...mountStmts) } + for (const slot of componentSlotPatches) { + const nav = navFor(slot.childPath) + stmts.push( + t.expressionStatement( + t.callExpression( + t.memberExpression( + t.memberExpression(nav, t.identifier('parentNode')), + t.identifier('replaceChild'), + ), + [ + t.memberExpression( + t.memberExpression(t.thisExpression(), t.identifier(slot.instanceVar)), + t.identifier('el'), + ), + nav, + ], + ), + ), + ) + } + stmts.push(js`return ${rootVar};`) return stmts } diff --git a/packages/vite-plugin-gea/src/codegen/generator.ts b/packages/vite-plugin-gea/src/codegen/generator.ts index b27820dd..6f7787bd 100644 --- a/packages/vite-plugin-gea/src/codegen/generator.ts +++ b/packages/vite-plugin-gea/src/codegen/generator.ts @@ -218,7 +218,6 @@ export function transformComponentFile( const preCloneEligible = t.isJSXElement(retStmt.argument) && preReturnStmts.length === 0 && - componentInstances.size === 0 && analysis.conditionalSlots.length === 0 && analysis.arrayMaps.length === 0 && analysis.unresolvedMaps.length === 0 && diff --git a/packages/vite-plugin-gea/tests/optimization-tests.test.ts b/packages/vite-plugin-gea/tests/optimization-tests.test.ts index 3c214b51..ede5a233 100644 --- a/packages/vite-plugin-gea/tests/optimization-tests.test.ts +++ b/packages/vite-plugin-gea/tests/optimization-tests.test.ts @@ -686,6 +686,162 @@ export default class GridStore extends Store { } }) +// --- P1-PERF-6: Partial clone optimization for components with child components --- +test('clone optimization: component with child components gets __tpl and __cloneTemplate', () => { + const source = ` + import { Component } from '@geajs/core' + import Header from './Header' + export default class MyComp extends Component { + template() { + return ( +
+
+

static text

+
+ ) + } + } + ` + const code = transformComponentSource(source) + assert.ok(code.includes('__tpl'), 'should have __tpl static field') + assert.ok(code.includes('GEA_CLONE_TEMPLATE'), 'should have [GEA_CLONE_TEMPLATE] method') +}) + +test('clone optimization: __cloneTemplate replaces placeholder with child component el', () => { + const source = ` + import { Component } from '@geajs/core' + import Header from './Header' + export default class MyComp extends Component { + template() { + return ( +
+
+

static text

+
+ ) + } + } + ` + const code = transformComponentSource(source) + assert.ok(code.includes('_header'), 'should reference header instance') + assert.ok(code.includes('.el'), 'should access child el') + assert.ok(code.includes('replaceChild'), 'should use replaceChild') +}) + +test('clone optimization: static html skeleton uses placeholder for child component', () => { + const source = ` + import { Component } from '@geajs/core' + import Panel from './Panel' + export default class MyComp extends Component { + template() { + return ( +
+ before + + after +
+ ) + } + } + ` + const code = transformComponentSource(source) + assert.ok(code.includes('data-gea-child-slot'), 'placeholder should be in static html') + assert.ok(code.includes('__tpl'), 'should have clone optimization') +}) + +test('clone optimization: component with dynamic class and child component', () => { + const source = ` + import { Component } from '@geajs/core' + import Footer from './Footer' + export default class MyComp extends Component { + template({ active }) { + return ( +
+
+
+ ) + } + } + ` + const code = transformComponentSource(source) + assert.ok(code.includes('__tpl'), 'should have clone optimization even with dynamic class') + assert.ok(code.includes('GEA_CLONE_TEMPLATE'), 'should have [GEA_CLONE_TEMPLATE]') + assert.ok(code.includes('replaceChild'), 'should replace placeholder') +}) + +test('clone optimization: runtime DOM - child slot placeholder is replaced after mount', async () => { + const restoreDom = installDom() + let app: { render: (el: HTMLElement) => void; dispose: () => void } | undefined + let root: HTMLElement | undefined + + try { + const seed = `opt-runtime-${Date.now()}-${Math.random()}` + const [{ default: Component }] = await loadRuntimeModules(seed) + + const headerSource = ` + import { Component } from '@geajs/core' + export default function Header() { + return
Hello
+ } + ` + const Header = await compileJsxComponent( + headerSource, + '/virtual/Header.jsx', + 'Header', + { Component }, + ) + + const parentSource = ` + import { Component } from '@geajs/core' + import Header from './Header' + export default class ParentComp extends Component { + template() { + return ( +
+
+

static text

+
+ ) + } + } + ` + const ParentComp = await compileJsxComponent( + parentSource, + '/virtual/ParentComp.jsx', + 'ParentComp', + { Component, Header }, + ) + + root = document.createElement('div') + document.body.appendChild(root) + app = new (ParentComp as new () => { render: (el: HTMLElement) => void; dispose: () => void })() + app.render(root) + await flushMicrotasks() + + assert.equal( + root.querySelector('[data-gea-child-slot]'), + null, + 'data-gea-child-slot placeholder should be removed after mount', + ) + + assert.ok( + root.querySelector('.the-header') !== null, + 'child component element (.the-header) should be inside root', + ) + + assert.ok( + root.querySelector('.wrapper') !== null, + 'parent wrapper element should be in root', + ) + + } finally { + app?.dispose() + await flushMicrotasks() + root?.remove() + restoreDom() + } +}) + test('selecting a cell in a grid should only mutate the old and new selected cells, not all cells', async () => { const restoreDom = installDom() From a0d721520af5c8c513a712f2e17969cae7f18e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 08:19:32 +0300 Subject: [PATCH 2/3] fix(vite-plugin): skip lazy child instances in clone slot collection Lazy children are not initialized at mount time, so dereferencing this..el for them produces a runtime error. Skip lazy instances when collecting component slot patches to ensure only guaranteed-constructed children get replaceChild calls. Co-Authored-By: Claude Sonnet 4.6 --- packages/vite-plugin-gea/src/codegen/gen-clone.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite-plugin-gea/src/codegen/gen-clone.ts b/packages/vite-plugin-gea/src/codegen/gen-clone.ts index e7139c92..cbb8e01e 100644 --- a/packages/vite-plugin-gea/src/codegen/gen-clone.ts +++ b/packages/vite-plugin-gea/src/codegen/gen-clone.ts @@ -502,7 +502,7 @@ function collectComponentSlotPatches( const instance = instances?.[cursor] if (instance) { instanceCursors.set(tag, cursor + 1) - if (!consumeOnly) { + if (!consumeOnly && !instance.lazy) { slots.push({ childPath: [...path, idx], instanceVar: instance.instanceVar }) } } From f92f83a21618707678db97b6c71097565af47786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 11:07:39 +0300 Subject: [PATCH 3/3] fix(vite-plugin): remove duplicate loadRuntimeModules/compileJsxComponent definitions Delete the local re-declarations of loadRuntimeModules and compileJsxComponent in optimization-tests.test.ts and use the imports from ./helpers/compile. Also add symbolsModule to loadRuntimeModules in helpers/compile.ts so all callers get consistent module bindings (component, store, symbols). Co-Authored-By: Claude Sonnet 4.6 --- .../vite-plugin-gea/tests/helpers/compile.ts | 5 +-- .../tests/optimization-tests.test.ts | 31 +------------------ 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/packages/vite-plugin-gea/tests/helpers/compile.ts b/packages/vite-plugin-gea/tests/helpers/compile.ts index b96d06d5..53ec898e 100644 --- a/packages/vite-plugin-gea/tests/helpers/compile.ts +++ b/packages/vite-plugin-gea/tests/helpers/compile.ts @@ -190,11 +190,12 @@ return ${className};` export async function loadRuntimeModules(seed: string) { const { default: ComponentManager } = await import('../../../gea/src/lib/base/component-manager') ComponentManager.instance = undefined - const [componentModule, storeModule] = await Promise.all([ + const [componentModule, storeModule, symbolsModule] = await Promise.all([ import(`../../../gea/src/lib/base/component.tsx?${seed}`), import(`../../../gea/src/lib/store.ts?${seed}`), + import(`../../../gea/src/lib/symbols.ts?${seed}`), ]) - return [componentModule, storeModule] + return [componentModule, storeModule, symbolsModule] as const } /** Same `Component` module as `@geajs/ui` and `RouterView` — required when mixing compiled examples with those packages (seeded `component.tsx?seed` breaks prototype checks). */ diff --git a/packages/vite-plugin-gea/tests/optimization-tests.test.ts b/packages/vite-plugin-gea/tests/optimization-tests.test.ts index ede5a233..546f1f53 100644 --- a/packages/vite-plugin-gea/tests/optimization-tests.test.ts +++ b/packages/vite-plugin-gea/tests/optimization-tests.test.ts @@ -10,7 +10,7 @@ import { JSDOM } from 'jsdom' import { parseSource } from '../src/parse/parser' import { transformComponentFile } from '../src/codegen/generator' import { geaPlugin } from '../src/index' -import { buildEvalPrelude, mergeEvalBindings } from './helpers/compile' +import { compileJsxComponent, loadRuntimeModules } from './helpers/compile' const generate = 'default' in babelGenerator ? babelGenerator.default : babelGenerator @@ -93,17 +93,6 @@ async function flushMicrotasks() { await new Promise((resolve) => setTimeout(resolve, 0)) } -async function loadRuntimeModules(seed: string) { - const { default: ComponentManager } = await import('../../gea/src/lib/base/component-manager.ts') - ComponentManager.instance = undefined - const [compMod, storeMod, symMod] = await Promise.all([ - import(`../../gea/src/lib/base/component.tsx?${seed}`), - import(`../../gea/src/lib/store.ts?${seed}`), - import(`../../gea/src/lib/symbols.ts?${seed}`), - ]) - return [compMod, storeMod, symMod] as const -} - // --- Optimization #3: Prop patch methods (inlined into GEA_ON_PROP_CHANGE) --- test('compiler inlines prop text patches into GEA_ON_PROP_CHANGE', () => { const output = transformComponentSource(` @@ -505,24 +494,6 @@ export default class MultiStore extends Store { } }) -async function compileJsxComponent(source: string, id: string, className: string, bindings: Record) { - const allBindings = mergeEvalBindings(bindings) - const plugin = geaPlugin() - const transform = typeof plugin.transform === 'function' ? plugin.transform : plugin.transform?.handler - const result = await transform?.call({} as never, source, id) - assert.ok(result) - - const code = typeof result === 'string' ? result : result.code - const compiledSource = `${buildEvalPrelude()}${code - .replace(/^import .*;$/gm, '') - .replaceAll('import.meta.hot', 'undefined') - .replaceAll('import.meta.url', '""') - .replace(/export default class\s+/, 'class ')} -return ${className};` - - return new Function(...Object.keys(allBindings), compiledSource)(...Object.values(allBindings)) -} - // --- setAttribute equality guard --- test('compiler should generate equality guard for setAttribute on attribute bindings', async () => {