From b34ebccad9edf327edf764d027c9c9a994e9de85 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Apr 2026 15:43:02 -0500 Subject: [PATCH 1/3] Restore a host-managed header slot in path-store trees This splits the first composition surface out of the broader Phase 5 work so the path-store lane can regain header composition before context-menu and icon work land. The file-tree now manages header slot content on the host, the virtualized view always exposes the slot outlet, and the docs demo plus targeted tests cover render, cleanup, SSR outlet, and hydration behavior. Constraint: User requested a header-slot-only commit before the rest of Phase 5 Rejected: Commit the full Phase 5 composition bundle together | needed a smaller reviewable slice Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep later composition surfaces reusing the host-managed slot path instead of introducing a separate header attachment mechanism Tested: Targeted oxlint on changed files; bun test packages/trees/test/path-store-header-slot.test.ts and packages/trees/test/path-store-render-scroll.test.ts; bun ws trees build; bun ws trees tsc Not-tested: Full root lint remains noisy from unrelated existing docs/demo files --- .../PathStorePoweredRenderDemoClient.tsx | 36 +++- packages/trees/src/path-store/file-tree.ts | 27 ++- packages/trees/src/path-store/index.ts | 2 + packages/trees/src/path-store/slotHost.ts | 50 +++++ packages/trees/src/path-store/types.ts | 9 + packages/trees/src/path-store/view.tsx | 2 + .../trees/test/path-store-header-slot.test.ts | 181 ++++++++++++++++++ 7 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 packages/trees/src/path-store/slotHost.ts create mode 100644 packages/trees/test/path-store-header-slot.test.ts diff --git a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx index 6b443599c..38f0f30bd 100644 --- a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx @@ -86,11 +86,36 @@ export function PathStorePoweredRenderDemoClient({ const options = useMemo( () => ({ ...sharedOptions, + composition: { + header: { + render: () => { + const header = document.createElement('div'); + header.style.alignItems = 'center'; + header.style.display = 'flex'; + header.style.gap = '12px'; + header.style.padding = '8px 12px'; + + const label = document.createElement('strong'); + label.textContent = 'Provisional header slot'; + header.append(label); + + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = 'Log header action'; + button.addEventListener('click', () => { + addLog('header action: clicked'); + }); + header.append(button); + + return header; + }, + }, + }, id: 'pst-phase4', onSelectionChange: handleSelectionChange, preparedInput, }), - [handleSelectionChange, preparedInput, sharedOptions] + [addLog, handleSelectionChange, preparedInput, sharedOptions] ); return ( @@ -99,21 +124,22 @@ export function PathStorePoweredRenderDemoClient({

Path-store lane ยท provisional

-

Focus + Selection

+

Focus + Selection + Header Slot

Phase 4 keeps the landed focus/navigation model and adds selection: click and keyboard selection semantics, path-first imperative item - methods, and lightweight selection-change observation in the existing + methods, lightweight selection-change observation, and now the first + simple composition surface via a slotted header in the existing path-store-powered demo.

} options={options} - title="Focus + Selection" + title="Focus + Selection + Header Slot" />
diff --git a/packages/trees/src/path-store/file-tree.ts b/packages/trees/src/path-store/file-tree.ts index 09546971b..8784ba0f9 100644 --- a/packages/trees/src/path-store/file-tree.ts +++ b/packages/trees/src/path-store/file-tree.ts @@ -7,7 +7,11 @@ import { ensureFileTreeStyles, FileTreeContainerLoaded, } from '../components/web-components'; -import { FILE_TREE_STYLE_ATTRIBUTE, FILE_TREE_TAG_NAME } from '../constants'; +import { + FILE_TREE_STYLE_ATTRIBUTE, + FILE_TREE_TAG_NAME, + HEADER_SLOT_NAME, +} from '../constants'; import fileTreeStyles from '../style.css'; import { PathStoreTreesController } from './controller'; import { @@ -15,11 +19,13 @@ import { renderPathStoreTreesRoot, unmountPathStoreTreesRoot, } from './runtime'; +import { PathStoreTreesManagedSlotHost } from './slotHost'; import type { PathStoreFileTreeOptions, PathStoreFileTreeSsrPayload, PathStoreTreeHydrationProps, PathStoreTreeRenderProps, + PathStoreTreesCompositionOptions, PathStoreTreesItemHandle, PathStoreTreesSelectionChangeListener, } from './types'; @@ -72,11 +78,13 @@ function ensureBuiltInSpriteSheet(shadowRoot: ShadowRoot): void { export class PathStoreFileTree { static LoadedCustomComponent: boolean = FileTreeContainerLoaded; + readonly #composition: PathStoreTreesCompositionOptions | undefined; readonly #controller: PathStoreTreesController; readonly #id: string; readonly #onSelectionChange: | PathStoreTreesSelectionChangeListener | undefined; + readonly #slotHost = new PathStoreTreesManagedSlotHost(); readonly #viewOptions: Pick< PathStoreFileTreeOptions, 'itemHeight' | 'overscan' | 'viewportHeight' @@ -88,6 +96,7 @@ export class PathStoreFileTree { public constructor(options: PathStoreFileTreeOptions) { const { + composition, id, itemHeight, onSelectionChange, @@ -95,6 +104,7 @@ export class PathStoreFileTree { viewportHeight, ...controllerOptions } = options; + this.#composition = composition; this.#id = createClientId(id); this.#onSelectionChange = onSelectionChange; this.#viewOptions = { @@ -118,6 +128,8 @@ export class PathStoreFileTree { delete this.#wrapper.dataset.fileTreeVirtualizedWrapper; this.#wrapper = undefined; } + this.#slotHost.clearAll(); + this.#slotHost.setHost(null); if (this.#fileTreeContainer != null) { delete this.#fileTreeContainer.dataset.fileTreeVirtualized; this.#fileTreeContainer = undefined; @@ -142,6 +154,7 @@ export class PathStoreFileTree { public hydrate({ fileTreeContainer }: PathStoreTreeHydrationProps): void { const host = this.#prepareHost(fileTreeContainer); const wrapper = this.#getOrCreateWrapper(host); + this.#syncHeaderSlotContent(); hydratePathStoreTreesRoot(wrapper, { controller: this.#controller, ...this.#getResolvedViewOptions(host), @@ -157,6 +170,7 @@ export class PathStoreFileTree { containerWrapper ); const wrapper = this.#getOrCreateWrapper(host); + this.#syncHeaderSlotContent(); renderPathStoreTreesRoot(wrapper, { controller: this.#controller, ...this.#getResolvedViewOptions(host), @@ -195,6 +209,16 @@ export class PathStoreFileTree { onSelectionChange(this.#controller.getSelectedPaths()); } + // Keeps header slot content attached to the host light DOM so hydration and + // later composition surfaces can share one host-managed slot path. + #syncHeaderSlotContent(): void { + const renderHeader = this.#composition?.header?.render; + this.#slotHost.setSlotContent( + HEADER_SLOT_NAME, + renderHeader == null ? null : renderHeader() + ); + } + #getOrCreateWrapper(host: HTMLElement): HTMLDivElement { if (this.#wrapper != null) { return this.#wrapper; @@ -239,6 +263,7 @@ export class PathStoreFileTree { ensureBuiltInSpriteSheet(shadowRoot); host.dataset.fileTreeVirtualized = 'true'; host.style.display = 'flex'; + this.#slotHost.setHost(host); this.#fileTreeContainer = host; return host; } diff --git a/packages/trees/src/path-store/index.ts b/packages/trees/src/path-store/index.ts index 940596763..b2460a69c 100644 --- a/packages/trees/src/path-store/index.ts +++ b/packages/trees/src/path-store/index.ts @@ -1,10 +1,12 @@ export { PathStoreTreesController } from './controller'; export { PathStoreFileTree, preloadPathStoreFileTree } from './file-tree'; export type { + PathStoreTreesCompositionOptions, PathStoreTreesDirectoryHandle, PathStoreFileTreeOptions, PathStoreFileTreeSsrPayload, PathStoreTreesFileHandle, + PathStoreTreesHeaderCompositionOptions, PathStoreTreeHydrationProps, PathStoreTreesItemHandle, PathStoreTreeRenderProps, diff --git a/packages/trees/src/path-store/slotHost.ts b/packages/trees/src/path-store/slotHost.ts new file mode 100644 index 000000000..f3a739902 --- /dev/null +++ b/packages/trees/src/path-store/slotHost.ts @@ -0,0 +1,50 @@ +// Tracks the library-owned slotted nodes so header content can move with the +// host element without clobbering user-managed light-DOM children. +export class PathStoreTreesManagedSlotHost { + #contentBySlot = new Map(); + #host: HTMLElement | null = null; + + public clearAll(): void { + for (const content of this.#contentBySlot.values()) { + content.remove(); + } + this.#contentBySlot.clear(); + } + + public setHost(host: HTMLElement | null): void { + this.#host = host; + if (host == null) { + return; + } + + for (const [slotName, content] of this.#contentBySlot) { + this.#attachContent(slotName, content); + } + } + + public setSlotContent(slotName: string, content: HTMLElement | null): void { + const currentContent = this.#contentBySlot.get(slotName) ?? null; + if (currentContent === content) { + if (content != null) { + this.#attachContent(slotName, content); + } + return; + } + + currentContent?.remove(); + if (content == null) { + this.#contentBySlot.delete(slotName); + return; + } + + this.#contentBySlot.set(slotName, content); + this.#attachContent(slotName, content); + } + + #attachContent(slotName: string, content: HTMLElement): void { + content.slot = slotName; + if (this.#host != null && content.parentNode !== this.#host) { + this.#host.appendChild(content); + } + } +} diff --git a/packages/trees/src/path-store/types.ts b/packages/trees/src/path-store/types.ts index 26ea184ba..32fbb58bf 100644 --- a/packages/trees/src/path-store/types.ts +++ b/packages/trees/src/path-store/types.ts @@ -69,6 +69,7 @@ export interface PathStoreTreesRenderOptions { export interface PathStoreFileTreeOptions extends PathStoreTreesControllerOptions, PathStoreTreesRenderOptions { + composition?: PathStoreTreesCompositionOptions; id?: string; onSelectionChange?: PathStoreTreesSelectionChangeListener; } @@ -117,3 +118,11 @@ export type PathStoreTreesControllerListener = () => void; export type PathStoreTreesSelectionChangeListener = ( selectedPaths: readonly PathStoreTreesPublicId[] ) => void; + +export interface PathStoreTreesHeaderCompositionOptions { + render?: () => HTMLElement | null; +} + +export interface PathStoreTreesCompositionOptions { + header?: PathStoreTreesHeaderCompositionOptions; +} diff --git a/packages/trees/src/path-store/view.tsx b/packages/trees/src/path-store/view.tsx index 6124374d5..55c02eece 100644 --- a/packages/trees/src/path-store/view.tsx +++ b/packages/trees/src/path-store/view.tsx @@ -5,6 +5,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks'; import { Icon } from '../components/Icon'; import { MiddleTruncate, Truncate } from '../components/OverflowText'; +import { HEADER_SLOT_NAME } from '../constants'; import { PathStoreTreesController } from './controller'; import type { PathStoreTreesDirectoryHandle, @@ -668,6 +669,7 @@ export function PathStoreTreesView({ data-path-store-guide-style="true" dangerouslySetInnerHTML={{ __html: guideStyleText }} /> +
', { + url: 'http://localhost', + }); + const originalValues = { + CSSStyleSheet: Reflect.get(globalThis, 'CSSStyleSheet'), + customElements: Reflect.get(globalThis, 'customElements'), + document: Reflect.get(globalThis, 'document'), + Event: Reflect.get(globalThis, 'Event'), + HTMLElement: Reflect.get(globalThis, 'HTMLElement'), + HTMLButtonElement: Reflect.get(globalThis, 'HTMLButtonElement'), + HTMLDivElement: Reflect.get(globalThis, 'HTMLDivElement'), + HTMLStyleElement: Reflect.get(globalThis, 'HTMLStyleElement'), + HTMLTemplateElement: Reflect.get(globalThis, 'HTMLTemplateElement'), + MutationObserver: Reflect.get(globalThis, 'MutationObserver'), + navigator: Reflect.get(globalThis, 'navigator'), + Node: Reflect.get(globalThis, 'Node'), + ResizeObserver: Reflect.get(globalThis, 'ResizeObserver'), + SVGElement: Reflect.get(globalThis, 'SVGElement'), + ShadowRoot: Reflect.get(globalThis, 'ShadowRoot'), + window: Reflect.get(globalThis, 'window'), + }; + + class MockStyleSheet { + replaceSync(_value: string): void {} + } + + class MockResizeObserver { + observe(_target: Element): void {} + disconnect(): void {} + } + + Object.assign(globalThis, { + CSSStyleSheet: MockStyleSheet, + customElements: dom.window.customElements, + document: dom.window.document, + Event: dom.window.Event, + HTMLElement: dom.window.HTMLElement, + HTMLButtonElement: dom.window.HTMLButtonElement, + HTMLDivElement: dom.window.HTMLDivElement, + HTMLStyleElement: dom.window.HTMLStyleElement, + HTMLTemplateElement: dom.window.HTMLTemplateElement, + MutationObserver: dom.window.MutationObserver, + navigator: dom.window.navigator, + Node: dom.window.Node, + ResizeObserver: MockResizeObserver, + SVGElement: dom.window.SVGElement, + ShadowRoot: dom.window.ShadowRoot, + window: dom.window, + }); + + return { + cleanup() { + for (const [key, value] of Object.entries(originalValues)) { + if (value === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.assign(globalThis, { [key]: value }); + } + } + dom.window.close(); + }, + dom, + }; +} + +async function flushDom(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('path-store header slot', () => { + test('preloadPathStoreFileTree includes the header slot outlet', async () => { + const { preloadPathStoreFileTree } = await import('../src/path-store'); + + const payload = preloadPathStoreFileTree({ + flattenEmptyDirectories: true, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + expect(payload.shadowHtml).toContain('slot name="header"'); + }); + + test('render attaches and cleanup removes host-managed header content', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const mount = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(mount); + + const fileTree = new PathStoreFileTree({ + composition: { + header: { + render: (): HTMLElement => { + const header = dom.window.document.createElement('button'); + header.dataset.testHeader = 'true'; + header.textContent = 'Header action'; + return header as unknown as HTMLElement; + }, + }, + }, + flattenEmptyDirectories: true, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper: mount }); + await flushDom(); + + const host = fileTree.getFileTreeContainer(); + expect(host?.querySelectorAll('[slot="header"]')).toHaveLength(1); + expect(host?.querySelector('[data-test-header="true"]')).not.toBeNull(); + + fileTree.cleanUp(); + expect(host?.querySelector('[slot="header"]')).toBeNull(); + } finally { + cleanup(); + } + }); + + test('hydrate keeps header slot content to a single host-managed node', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree, preloadPathStoreFileTree } = + await import('../src/path-store'); + const payload = preloadPathStoreFileTree({ + flattenEmptyDirectories: true, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + const mount = dom.window.document.createElement('div'); + mount.innerHTML = payload.html; + dom.window.document.body.appendChild(mount); + + const host = mount.querySelector('file-tree-container'); + if (!(host instanceof dom.window.HTMLElement)) { + throw new Error('expected SSR host'); + } + + const fileTree = new PathStoreFileTree({ + composition: { + header: { + render: (): HTMLElement => { + const header = dom.window.document.createElement('div'); + header.dataset.testHydratedHeader = 'true'; + header.textContent = 'Hydrated header'; + return header as unknown as HTMLElement; + }, + }, + }, + flattenEmptyDirectories: true, + id: payload.id, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + fileTree.hydrate({ fileTreeContainer: host }); + await flushDom(); + fileTree.render({ fileTreeContainer: host }); + await flushDom(); + + expect(host.querySelectorAll('[slot="header"]')).toHaveLength(1); + expect( + host.querySelector('[data-test-hydrated-header="true"]') + ).not.toBeNull(); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); +}); From ed9c50186f4ecad462ecbeea73ba642bcd98e5b1 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Apr 2026 16:21:30 -0500 Subject: [PATCH 2/3] make sure header slots works with ssr --- .../PathStorePoweredRenderDemoClient.tsx | 2 ++ .../app/trees-dev/path-store-powered/page.tsx | 7 ++++++ packages/trees/src/path-store/file-tree.ts | 22 ++++++++++++++++++- packages/trees/src/path-store/slotHost.ts | 18 +++++++++++++++ packages/trees/src/path-store/types.ts | 1 + .../trees/test/path-store-header-slot.test.ts | 16 ++++++++++++++ 6 files changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx index 38f0f30bd..415af3b25 100644 --- a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx @@ -87,7 +87,9 @@ export function PathStorePoweredRenderDemoClient({ () => ({ ...sharedOptions, composition: { + ...sharedOptions.composition, header: { + ...sharedOptions.composition?.header, render: () => { const header = document.createElement('div'); header.style.alignItems = 'center'; diff --git a/apps/docs/app/trees-dev/path-store-powered/page.tsx b/apps/docs/app/trees-dev/path-store-powered/page.tsx index 7eab1bd74..2114e30f7 100644 --- a/apps/docs/app/trees-dev/path-store-powered/page.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/page.tsx @@ -11,10 +11,17 @@ const linuxKernelWorkload = getVirtualizationWorkload('linux-1x'); const linuxKernelPreparedInput = createPresortedPreparedInput( linuxKernelWorkload.files ); +const PATH_STORE_HEADER_HTML = + '
Provisional header slot
'; export default function PathStorePoweredPage() { const sharedOptions: Omit = { + composition: { + header: { + html: PATH_STORE_HEADER_HTML, + }, + }, flattenEmptyDirectories: true, initialExpandedPaths: linuxKernelWorkload.expandedFolders, paths: linuxKernelWorkload.files, diff --git a/packages/trees/src/path-store/file-tree.ts b/packages/trees/src/path-store/file-tree.ts index 8784ba0f9..bf17070c0 100644 --- a/packages/trees/src/path-store/file-tree.ts +++ b/packages/trees/src/path-store/file-tree.ts @@ -64,6 +64,17 @@ function parseSpriteSheet(spriteSheet: string): SVGElement | undefined { return svg instanceof SVGElement ? svg : undefined; } +function getHeaderSlotHtml( + composition: PathStoreTreesCompositionOptions | undefined +): string { + const headerHtml = composition?.header?.html?.trim(); + if (headerHtml == null || headerHtml.length === 0) { + return ''; + } + + return `
${headerHtml}
`; +} + function ensureBuiltInSpriteSheet(shadowRoot: ShadowRoot): void { if (shadowRoot.querySelector('svg[data-icon-sprite]') != null) { return; @@ -212,6 +223,13 @@ export class PathStoreFileTree { // Keeps header slot content attached to the host light DOM so hydration and // later composition surfaces can share one host-managed slot path. #syncHeaderSlotContent(): void { + if ( + this.#composition?.header != null && + this.#composition.header.render == null + ) { + return; + } + const renderHeader = this.#composition?.header?.render; this.#slotHost.setSlotContent( HEADER_SLOT_NAME, @@ -273,6 +291,7 @@ export function preloadPathStoreFileTree( options: PathStoreFileTreeOptions ): PathStoreFileTreeSsrPayload { const { + composition, id, itemHeight, onSelectionChange: _onSelectionChange, @@ -296,7 +315,8 @@ export function preloadPathStoreFileTree( controller.destroy(); const shadowHtml = `${getBuiltInSpriteSheet('minimal')}
${bodyHtml}
`; - const html = ``; + const headerSlotHtml = getHeaderSlotHtml(composition); + const html = `${headerSlotHtml}`; return { html, id: resolvedId, diff --git a/packages/trees/src/path-store/slotHost.ts b/packages/trees/src/path-store/slotHost.ts index f3a739902..11336fd28 100644 --- a/packages/trees/src/path-store/slotHost.ts +++ b/packages/trees/src/path-store/slotHost.ts @@ -17,6 +17,8 @@ export class PathStoreTreesManagedSlotHost { return; } + this.#adoptExistingManagedContent(host); + for (const [slotName, content] of this.#contentBySlot) { this.#attachContent(slotName, content); } @@ -43,8 +45,24 @@ export class PathStoreTreesManagedSlotHost { #attachContent(slotName: string, content: HTMLElement): void { content.slot = slotName; + content.dataset.pathStoreManagedSlot = slotName; if (this.#host != null && content.parentNode !== this.#host) { this.#host.appendChild(content); } } + + #adoptExistingManagedContent(host: HTMLElement): void { + for (const element of Array.from(host.children)) { + if (!(element instanceof HTMLElement)) { + continue; + } + + const slotName = element.dataset.pathStoreManagedSlot; + if (slotName == null || this.#contentBySlot.has(slotName)) { + continue; + } + + this.#contentBySlot.set(slotName, element); + } + } } diff --git a/packages/trees/src/path-store/types.ts b/packages/trees/src/path-store/types.ts index 32fbb58bf..70147b63b 100644 --- a/packages/trees/src/path-store/types.ts +++ b/packages/trees/src/path-store/types.ts @@ -120,6 +120,7 @@ export type PathStoreTreesSelectionChangeListener = ( ) => void; export interface PathStoreTreesHeaderCompositionOptions { + html?: string; render?: () => HTMLElement | null; } diff --git a/packages/trees/test/path-store-header-slot.test.ts b/packages/trees/test/path-store-header-slot.test.ts index a0b43fca9..a54e6e7bf 100644 --- a/packages/trees/test/path-store-header-slot.test.ts +++ b/packages/trees/test/path-store-header-slot.test.ts @@ -77,6 +77,11 @@ describe('path-store header slot', () => { const { preloadPathStoreFileTree } = await import('../src/path-store'); const payload = preloadPathStoreFileTree({ + composition: { + header: { + html: '', + }, + }, flattenEmptyDirectories: true, initialExpansion: 'open', paths: ['README.md', 'src/index.ts'], @@ -84,6 +89,8 @@ describe('path-store header slot', () => { }); expect(payload.shadowHtml).toContain('slot name="header"'); + expect(payload.html).toContain('slot="header"'); + expect(payload.html).toContain('data-test-ssr-header'); }); test('render attaches and cleanup removes host-managed header content', async () => { @@ -130,6 +137,11 @@ describe('path-store header slot', () => { const { PathStoreFileTree, preloadPathStoreFileTree } = await import('../src/path-store'); const payload = preloadPathStoreFileTree({ + composition: { + header: { + html: '
Hydrated header
', + }, + }, flattenEmptyDirectories: true, initialExpansion: 'open', paths: ['README.md', 'src/index.ts'], @@ -144,6 +156,7 @@ describe('path-store header slot', () => { if (!(host instanceof dom.window.HTMLElement)) { throw new Error('expected SSR host'); } + expect(host.querySelectorAll('[slot="header"]')).toHaveLength(1); const fileTree = new PathStoreFileTree({ composition: { @@ -172,6 +185,9 @@ describe('path-store header slot', () => { expect( host.querySelector('[data-test-hydrated-header="true"]') ).not.toBeNull(); + expect( + host.querySelectorAll('[data-test-ssr-header="true"]') + ).toHaveLength(0); fileTree.cleanUp(); } finally { From 45e2edb85fbe61dc747700b3e8991ba3c21bf3d1 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Apr 2026 16:50:58 -0500 Subject: [PATCH 3/3] Support html-only header composition on client render --- packages/trees/src/path-store/file-tree.ts | 12 +++---- packages/trees/src/path-store/slotHost.ts | 18 ++++++++++ .../trees/test/path-store-header-slot.test.ts | 35 +++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/trees/src/path-store/file-tree.ts b/packages/trees/src/path-store/file-tree.ts index bf17070c0..7fa646128 100644 --- a/packages/trees/src/path-store/file-tree.ts +++ b/packages/trees/src/path-store/file-tree.ts @@ -223,17 +223,15 @@ export class PathStoreFileTree { // Keeps header slot content attached to the host light DOM so hydration and // later composition surfaces can share one host-managed slot path. #syncHeaderSlotContent(): void { - if ( - this.#composition?.header != null && - this.#composition.header.render == null - ) { + const renderHeader = this.#composition?.header?.render; + if (renderHeader != null) { + this.#slotHost.setSlotContent(HEADER_SLOT_NAME, renderHeader()); return; } - const renderHeader = this.#composition?.header?.render; - this.#slotHost.setSlotContent( + this.#slotHost.setSlotHtml( HEADER_SLOT_NAME, - renderHeader == null ? null : renderHeader() + this.#composition?.header?.html ?? null ); } diff --git a/packages/trees/src/path-store/slotHost.ts b/packages/trees/src/path-store/slotHost.ts index 11336fd28..67982edf3 100644 --- a/packages/trees/src/path-store/slotHost.ts +++ b/packages/trees/src/path-store/slotHost.ts @@ -43,6 +43,24 @@ export class PathStoreTreesManagedSlotHost { this.#attachContent(slotName, content); } + public setSlotHtml(slotName: string, html: string | null): void { + const normalizedHtml = html?.trim() ?? ''; + if (normalizedHtml.length === 0) { + this.setSlotContent(slotName, null); + return; + } + + const currentContent = this.#contentBySlot.get(slotName) ?? null; + if (currentContent != null && currentContent.innerHTML === normalizedHtml) { + this.#attachContent(slotName, currentContent); + return; + } + + const nextContent = document.createElement('div'); + nextContent.innerHTML = normalizedHtml; + this.setSlotContent(slotName, nextContent); + } + #attachContent(slotName: string, content: HTMLElement): void { content.slot = slotName; content.dataset.pathStoreManagedSlot = slotName; diff --git a/packages/trees/test/path-store-header-slot.test.ts b/packages/trees/test/path-store-header-slot.test.ts index a54e6e7bf..eede4e416 100644 --- a/packages/trees/test/path-store-header-slot.test.ts +++ b/packages/trees/test/path-store-header-slot.test.ts @@ -131,6 +131,41 @@ describe('path-store header slot', () => { } }); + test('render attaches HTML-only header content without requiring SSR markup', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const mount = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(mount); + + const fileTree = new PathStoreFileTree({ + composition: { + header: { + html: '', + }, + }, + flattenEmptyDirectories: true, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper: mount }); + await flushDom(); + + const host = fileTree.getFileTreeContainer(); + expect(host?.querySelectorAll('[slot="header"]')).toHaveLength(1); + expect( + host?.querySelector('[data-test-html-header="true"]')?.textContent + ).toBe('HTML header'); + + fileTree.cleanUp(); + expect(host?.querySelector('[slot="header"]')).toBeNull(); + } finally { + cleanup(); + } + }); + test('hydrate keeps header slot content to a single host-managed node', async () => { const { cleanup, dom } = installDom(); try {