diff --git a/packages/qwik/src/core/api.md b/packages/qwik/src/core/api.md index 654cf75c6e4..7da187163e4 100644 --- a/packages/qwik/src/core/api.md +++ b/packages/qwik/src/core/api.md @@ -543,6 +543,32 @@ export const _isJSXNode: (n: unknown) => n is JSXNode; // @public (undocumented) export const isSignal: (value: any) => value is Signal; +// Warning: (ae-internal-missing-underscore) The name "ISsrComponentFrame" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export interface ISsrComponentFrame { + // Warning: (ae-forgotten-export) The symbol "ISsrNode" needs to be exported by the entry point index.d.ts + // + // (undocumented) + componentNode: ISsrNode; + // (undocumented) + consumeChildrenForSlot(projectionNode: ISsrNode, slotName: string): JSXChildren | null; + // (undocumented) + distributeChildrenIntoSlots(children: JSXChildren, parentScopedStyle: string | null, parentComponentFrame: ISsrComponentFrame | null): void; + // (undocumented) + hasSlot(slotName: string): boolean; + // (undocumented) + projectionComponentFrame: ISsrComponentFrame | null; + // (undocumented) + projectionDepth: number; + // (undocumented) + projectionScopedStyle: string | null; + // (undocumented) + releaseUnclaimedProjections(unclaimedProjections: (ISsrComponentFrame | JSXChildren | string)[]): void; + // (undocumented) + scopedStyleIds: Set; +} + // @internal (undocumented) export function _isStringifiable(value: unknown): value is _Stringifiable; @@ -2049,10 +2075,18 @@ export const _waitUntilRendered: (elm: Element) => Promise; // Warning: (ae-forgotten-export) The symbol "SSRContainer" needs to be exported by the entry point index.d.ts // // @internal (undocumented) -export function _walkJSX(ssr: SSRContainer, value: JSXOutput, allowPromises: true, currentStyleScoped: string | null): ValueOrPromise; +export function _walkJSX(ssr: SSRContainer, value: JSXOutput, options: { + allowPromises: true; + currentStyleScoped: string | null; + parentComponentFrame: ISsrComponentFrame | null; +}): ValueOrPromise; // @internal (undocumented) -export function _walkJSX(ssr: SSRContainer, value: JSXOutput, allowPromises: false, currentStyleScoped: string | null): false; +export function _walkJSX(ssr: SSRContainer, value: JSXOutput, options: { + allowPromises: false; + currentStyleScoped: string | null; + parentComponentFrame: ISsrComponentFrame | null; +}): false; // @internal (undocumented) export const _weakSerialize: (input: T) => Partial; diff --git a/packages/qwik/src/core/debug.ts b/packages/qwik/src/core/debug.ts index 7646e9943f7..c611a5f8a61 100644 --- a/packages/qwik/src/core/debug.ts +++ b/packages/qwik/src/core/debug.ts @@ -1,9 +1,10 @@ import { isQrl } from '../server/prefetch-strategy'; import { isJSXNode } from './shared/jsx/jsx-runtime'; import { isTask } from './use/use-task'; -import { vnode_isVNode, vnode_toString } from './client/vnode'; +import { vnode_getProp, vnode_isVNode } from './client/vnode'; import { ComputedSignal, WrappedSignal, isSignal } from './signal/signal'; import { isStore } from './signal/store'; +import { DEBUG_TYPE } from './shared/types'; const stringifyPath: any[] = []; export function qwikDebugToString(value: any): any { @@ -30,7 +31,7 @@ export function qwikDebugToString(value: any): any { stringifyPath.push(value); if (Array.isArray(value)) { if (vnode_isVNode(value)) { - return vnode_toString.apply(value); + return '(' + vnode_getProp(value, DEBUG_TYPE, null) + ')'; } else { return value.map(qwikDebugToString); } diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index 42c3020cea4..2c3078c8c78 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -22,6 +22,7 @@ export type { SnapshotMeta, SnapshotMetaValue, SnapshotListener, + ISsrComponentFrame, } from './ssr/ssr-types'; ////////////////////////////////////////////////////////////////////////////////////////// diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts index 9fe0c6d9832..cbd5fd09b4b 100644 --- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts +++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts @@ -31,44 +31,56 @@ import { qrlToString, type SerializationContext } from '../shared/shared-seriali import { DEBUG_TYPE, VirtualType } from '../shared/types'; import { WrappedSignal, EffectProperty, isSignal } from '../signal/signal'; import { applyInlineComponent, applyQwikComponentBody } from './ssr-render-component'; -import type { ISsrNode, SSRContainer, SsrAttrs } from './ssr-types'; +import type { ISsrComponentFrame, ISsrNode, SSRContainer, SsrAttrs } from './ssr-types'; import { qInspector } from '../shared/utils/qdev'; import { serializeAttribute } from '../shared/utils/styles'; -class SetScopedStyle { - constructor(public $scopedStyle$: string | null) {} +class ParentComponentData { + constructor( + public $scopedStyle$: string | null, + public $componentFrame$: ISsrComponentFrame | null + ) {} } type StackFn = () => ValueOrPromise; type StackValue = ValueOrPromise< - JSXOutput | StackFn | Promise | typeof Promise | SetScopedStyle | AsyncGenerator + JSXOutput | StackFn | Promise | typeof Promise | ParentComponentData | AsyncGenerator >; /** @internal */ export function _walkJSX( ssr: SSRContainer, value: JSXOutput, - allowPromises: true, - currentStyleScoped: string | null + options: { + allowPromises: true; + currentStyleScoped: string | null; + parentComponentFrame: ISsrComponentFrame | null; + } ): ValueOrPromise; /** @internal */ export function _walkJSX( ssr: SSRContainer, value: JSXOutput, - allowPromises: false, - currentStyleScoped: string | null + options: { + allowPromises: false; + currentStyleScoped: string | null; + parentComponentFrame: ISsrComponentFrame | null; + } ): false; /** @internal */ export function _walkJSX( ssr: SSRContainer, value: JSXOutput, - allowPromises: boolean, - currentStyleScoped: string | null + options: { + allowPromises: boolean; + currentStyleScoped: string | null; + parentComponentFrame: ISsrComponentFrame | null; + } ): ValueOrPromise | false { const stack: StackValue[] = [value]; let resolveDrain: () => void; let rejectDrain: (reason: any) => void; const drained = - allowPromises && + options.allowPromises && new Promise((res, rej) => { resolveDrain = res; rejectDrain = rej; @@ -81,12 +93,13 @@ export function _walkJSX( const drain = (): void => { while (stack.length) { const value = stack.pop(); - if (value instanceof SetScopedStyle) { - currentStyleScoped = value.$scopedStyle$; + if (value instanceof ParentComponentData) { + options.currentStyleScoped = value.$scopedStyle$; + options.parentComponentFrame = value.$componentFrame$; continue; } else if (typeof value === 'function') { if (value === Promise) { - if (!allowPromises) { + if (!options.allowPromises) { return throwErrorAndStop('Promises not expected here.'); } (stack.pop() as Promise).then(resolveValue, rejectDrain); @@ -94,7 +107,7 @@ export function _walkJSX( } const waitOn = (value as StackFn).apply(ssr); if (waitOn) { - if (!allowPromises) { + if (!options.allowPromises) { return throwErrorAndStop('Promises not expected here.'); } waitOn.then(drain, rejectDrain); @@ -102,9 +115,12 @@ export function _walkJSX( } continue; } - processJSXNode(ssr, enqueue, value as JSXOutput, currentStyleScoped); + processJSXNode(ssr, enqueue, value as JSXOutput, { + styleScoped: options.currentStyleScoped, + parentComponentFrame: options.parentComponentFrame, + }); } - if (stack.length === 0 && allowPromises) { + if (stack.length === 0 && options.allowPromises) { resolveDrain(); } }; @@ -116,7 +132,10 @@ function processJSXNode( ssr: SSRContainer, enqueue: (value: StackValue) => void, value: JSXOutput, - styleScoped: string | null + options: { + styleScoped: string | null; + parentComponentFrame: ISsrComponentFrame | null; + } ) { // console.log('processJSXNode', value); if (value === null || value === undefined) { @@ -146,7 +165,11 @@ function processJSXNode( } else if (isAsyncGenerator(value)) { enqueue(async () => { for await (const chunk of value) { - await _walkJSX(ssr, chunk as JSXOutput, true, styleScoped); + await _walkJSX(ssr, chunk as JSXOutput, { + allowPromises: true, + currentStyleScoped: options.styleScoped, + parentComponentFrame: options.parentComponentFrame, + }); ssr.commentNode(FLUSH_COMMENT); } }); @@ -155,7 +178,7 @@ function processJSXNode( const type = jsx.type; // Below, JSXChildren allows functions and regexes, but we assume the dev only uses those as appropriate. if (typeof type === 'string') { - appendClassIfScopedStyleExists(jsx, styleScoped); + appendClassIfScopedStyleExists(jsx, options.styleScoped); appendQwikInspectorAttribute(jsx); const innerHTML = ssr.openElement( @@ -164,10 +187,15 @@ function processJSXNode( jsx.varProps, jsx.constProps, ssr.serializationCtx, - styleScoped, + options.styleScoped, jsx.key ), - constPropsToSsrAttrs(jsx.constProps, jsx.varProps, ssr.serializationCtx, styleScoped) + constPropsToSsrAttrs( + jsx.constProps, + jsx.varProps, + ssr.serializationCtx, + options.styleScoped + ) ); if (innerHTML) { ssr.htmlNode(innerHTML); @@ -197,17 +225,17 @@ function processJSXNode( children != null && enqueue(children); } else if (type === Slot) { const componentFrame = - ssr.getNearestComponentFrame() || ssr.unclaimedProjectionComponentFrameQueue.shift(); - const projectionAttrs = isDev ? [DEBUG_TYPE, VirtualType.Projection] : []; + options.parentComponentFrame || ssr.unclaimedProjectionComponentFrameQueue.shift(); if (componentFrame) { const compId = componentFrame.componentNode.id || ''; + const projectionAttrs = isDev ? [DEBUG_TYPE, VirtualType.Projection] : []; projectionAttrs.push(':', compId); ssr.openProjection(projectionAttrs); const host = componentFrame.componentNode; const node = ssr.getLastNode(); const slotName = getSlotName(host, jsx, ssr); projectionAttrs.push(QSlot, slotName); - enqueue(new SetScopedStyle(styleScoped)); + enqueue(new ParentComponentData(options.styleScoped, options.parentComponentFrame)); enqueue(ssr.closeProjection); const slotDefaultChildren: JSXChildren | null = jsx.children || null; const slotChildren = @@ -216,7 +244,12 @@ function processJSXNode( ssr.addUnclaimedProjection(componentFrame, QDefaultSlot, slotDefaultChildren); } enqueue(slotChildren as JSXOutput); - enqueue(new SetScopedStyle(componentFrame.childrenScopedStyle)); + enqueue( + new ParentComponentData( + componentFrame.projectionScopedStyle, + componentFrame.projectionComponentFrame + ) + ); } else { // Even thought we are not projecting we still need to leave a marker for the slot. ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.Projection] : EMPTY_ARRAY); @@ -231,7 +264,11 @@ function processJSXNode( if (isFunction(generator)) { value = generator({ async write(chunk) { - await _walkJSX(ssr, chunk as JSXOutput, true, styleScoped); + await _walkJSX(ssr, chunk as JSXOutput, { + allowPromises: true, + currentStyleScoped: options.styleScoped, + parentComponentFrame: options.parentComponentFrame, + }); ssr.commentNode(FLUSH_COMMENT); }, }); @@ -247,14 +284,19 @@ function processJSXNode( // prod: use new instance of an array for props, we always modify props for a component ssr.openComponent(isDev ? [DEBUG_TYPE, VirtualType.Component] : []); const host = ssr.getLastNode(); - ssr.getComponentFrame(0)!.distributeChildrenIntoSlots(jsx.children, styleScoped); + const componentFrame = ssr.getParentComponentFrame()!; + componentFrame!.distributeChildrenIntoSlots( + jsx.children, + options.styleScoped, + options.parentComponentFrame + ); const jsxOutput = applyQwikComponentBody(ssr, jsx, type); const compStyleComponentId = addComponentStylePrefix(host.getProp(QScopedStyle)); - enqueue(new SetScopedStyle(styleScoped)); + enqueue(new ParentComponentData(options.styleScoped, options.parentComponentFrame)); enqueue(ssr.closeComponent); enqueue(jsxOutput); isPromise(jsxOutput) && enqueue(Promise); - enqueue(new SetScopedStyle(compStyleComponentId)); + enqueue(new ParentComponentData(compStyleComponentId, componentFrame)); } else { ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.InlineComponent] : EMPTY_ARRAY); enqueue(ssr.closeFragment); diff --git a/packages/qwik/src/core/ssr/ssr-types.ts b/packages/qwik/src/core/ssr/ssr-types.ts index 0c0c5f8cc97..4b5551a3b63 100644 --- a/packages/qwik/src/core/ssr/ssr-types.ts +++ b/packages/qwik/src/core/ssr/ssr-types.ts @@ -30,16 +30,22 @@ export interface ISsrNode { removeProp(name: string): void; } +/** @internal */ export interface ISsrComponentFrame { componentNode: ISsrNode; scopedStyleIds: Set; - childrenScopedStyle: string | null; + projectionScopedStyle: string | null; + projectionComponentFrame: ISsrComponentFrame | null; projectionDepth: number; releaseUnclaimedProjections( unclaimedProjections: (ISsrComponentFrame | JSXChildren | string)[] ): void; consumeChildrenForSlot(projectionNode: ISsrNode, slotName: string): JSXChildren | null; - distributeChildrenIntoSlots(children: JSXChildren, scopedStyle: string | null): void; + distributeChildrenIntoSlots( + children: JSXChildren, + parentScopedStyle: string | null, + parentComponentFrame: ISsrComponentFrame | null + ): void; hasSlot(slotName: string): boolean; } @@ -74,7 +80,7 @@ export interface SSRContainer extends Container { openComponent(attrs: SsrAttrs): void; getComponentFrame(projectionDepth: number): ISsrComponentFrame | null; - getNearestComponentFrame(): ISsrComponentFrame | null; + getParentComponentFrame(): ISsrComponentFrame | null; closeComponent(): void; textNode(text: string): void; diff --git a/packages/qwik/src/core/tests/projection.spec.tsx b/packages/qwik/src/core/tests/projection.spec.tsx index 758a807ecc6..93b3d04fcbd 100644 --- a/packages/qwik/src/core/tests/projection.spec.tsx +++ b/packages/qwik/src/core/tests/projection.spec.tsx @@ -810,6 +810,124 @@ describe.each([ ); }); + it('should render nested projections in the same component with slots correctly', async () => { + const CompTwo = component$(() => { + return ( +
+ +
+ ); + }); + + const CompThree = component$(() => { + return ( +
+ +
+ ); + }); + + const CompFour = component$(() => { + return ( +
+ +
+ ); + }); + + const CompOne = component$(() => { + return ( + + + + + + + + ); + }); + + const { vNode } = await render(Hey, { debug: DEBUG }); + + expect(vNode).toMatchVDOM( + + +
+ + +
+ + +
+ + {'Hey'} + +
+
+
+
+
+
+
+
+
+ ); + }); + + it('should render nested projections in the same component with slots correctly', async () => { + const Button = component$(() => { + return ; + }); + + const Thing = component$(() => { + return ; + }); + + const Projector = component$(() => { + return ( + + ); + }); + + const Parent = component$(() => { + return ( + <> + + {<>INSIDE THING} + + + ); + }); + + const { vNode } = await render(, { debug: DEBUG }); + + expect(vNode).toMatchVDOM( + + + + + + + + + + {'INSIDE THING'} + + + + + + + + + + ); + }); + describe('ensureProjectionResolved', () => { (globalThis as any).log = [] as string[]; beforeEach(() => { @@ -1672,7 +1790,7 @@ describe.each([ describe('regression', () => { it('#1630', async () => { const Child = component$(() => CHILD); - const Issue1630 = component$((props) => { + const Issue1630 = component$(() => { const store = useStore({ open: true }); return (
@@ -1740,7 +1858,7 @@ describe.each([ )); - const Issue1630 = component$((props) => { + const Issue1630 = component$(() => { const store = useStore({ open: true }); return (
@@ -2231,7 +2349,7 @@ describe.each([ ); }); - it('#6900 - signals should be ordered so parents run first', async () => { + it('#6900 - should not execute chores for deleted nodes inside projection', async () => { const Issue6900Root = component$(() => ); const Issue6900Image = component$<{ src: string }>(({ src }) => src); @@ -2275,48 +2393,4 @@ describe.each([ ); }); }); - - it('#6900 - should not execute chores for deleted nodes inside projection', async () => { - const Issue6900Root = component$(() => ); - const Issue6900Image = component$<{ src: string }>(({ src }) => src); - - const Issue6900 = component$(() => { - const signal = useSignal({ url: 'https://picsum.photos/200' }); - if (!signal.value) { - return

User is not signed in

; - } - - return ( -
- - - - -
- ); - }); - - const { vNode, document } = await render(, { debug: DEBUG }); - - expect(vNode).toMatchVDOM( - -
- - - - https://picsum.photos/200 - - -
-
- ); - - await trigger(document.body, 'button', 'click'); - - expect(vNode).toMatchVDOM( - -

User is not signed in

-
- ); - }); }); diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts index e0e9f9c403a..d514c9558f0 100644 --- a/packages/qwik/src/server/ssr-container.ts +++ b/packages/qwik/src/server/ssr-container.ts @@ -253,7 +253,11 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { async render(jsx: JSXOutput) { this.openContainer(); - await _walkJSX(this, jsx, true, null); + await _walkJSX(this, jsx, { + allowPromises: true, + currentStyleScoped: null, + parentComponentFrame: this.getComponentFrame(), + }); await this.closeContainer(); } @@ -458,7 +462,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { openComponent(attrs: SsrAttrs) { this.openFragment(attrs); this.currentComponentNode = this.getLastNode(); - this.componentStack.push(new SsrComponentFrame(this.getLastNode())); + this.componentStack.push(new SsrComponentFrame(this.currentComponentNode)); } /** @@ -474,12 +478,9 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { return idx >= 0 ? this.componentStack[idx] : null; } - getNearestComponentFrame(): ISsrComponentFrame | null { - const currentFrame = this.getComponentFrame(0); - if (!currentFrame) { - return null; - } - return this.getComponentFrame(currentFrame.projectionDepth); + getParentComponentFrame(): ISsrComponentFrame | null { + const localProjectionDepth = this.getComponentFrame()?.projectionDepth || 0; + return this.getComponentFrame(localProjectionDepth); } /** Writes closing data to vNodeData for component boundaries and mark unclaimed projections */ @@ -941,7 +942,11 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { : [QSlotParent, ssrComponentNode!.id] ); ssrComponentNode?.setProp(value, this.getLastNode().id); - _walkJSX(this, children, false, scopedStyleId); + _walkJSX(this, children, { + allowPromises: false, + currentStyleScoped: scopedStyleId, + parentComponentFrame: null, + }); this.closeFragment(); } else { throw Error(); // 'should not get here' diff --git a/packages/qwik/src/server/ssr-node.ts b/packages/qwik/src/server/ssr-node.ts index 407e2e3c931..698fe040cf5 100644 --- a/packages/qwik/src/server/ssr-node.ts +++ b/packages/qwik/src/server/ssr-node.ts @@ -17,7 +17,7 @@ import type { CleanupQueue } from './ssr-container'; * Server has no DOM, so we need to create a fake node to represent the DOM for serialization * purposes. * - * Once deserialized the client, they will be turned to actual DOM nodes. + * Once deserialized the client, they will be turned to ElementVNodes. */ export class SsrNode implements ISsrNode { __brand__!: 'HostElement'; @@ -111,11 +111,17 @@ export class SsrComponentFrame implements ISsrComponentFrame { public slots = []; public projectionDepth = 0; public scopedStyleIds = new Set(); - public childrenScopedStyle: string | null = null; + public projectionScopedStyle: string | null = null; + public projectionComponentFrame: SsrComponentFrame | null = null; constructor(public componentNode: ISsrNode) {} - distributeChildrenIntoSlots(children: JSXChildren, scopedStyle: string | null) { - this.childrenScopedStyle = scopedStyle; + distributeChildrenIntoSlots( + children: JSXChildren, + projectionScopedStyle: string | null, + projectionComponentFrame: SsrComponentFrame | null + ) { + this.projectionScopedStyle = projectionScopedStyle; + this.projectionComponentFrame = projectionComponentFrame; if (isJSXNode(children)) { const slotName = this.getSlotName(children); mapArray_set(this.slots, slotName, children, 0); @@ -140,7 +146,7 @@ export class SsrComponentFrame implements ISsrComponentFrame { } } - private updateSlot(slotName: string, child: JSXNode) { + private updateSlot(slotName: string, child: JSXChildren) { // we need to check if the slot already has a value let existingSlots = mapArray_get(this.slots, slotName, 0); if (existingSlots === null) { @@ -179,7 +185,7 @@ export class SsrComponentFrame implements ISsrComponentFrame { releaseUnclaimedProjections(unclaimedProjections: (ISsrComponentFrame | JSXChildren | string)[]) { if (this.slots.length) { unclaimedProjections.push(this); - unclaimedProjections.push(this.childrenScopedStyle); + unclaimedProjections.push(this.projectionScopedStyle); unclaimedProjections.push.apply(unclaimedProjections, this.slots); } }