Skip to content

Commit

Permalink
fix: rendering attribute value from array of classes from spread props
Browse files Browse the repository at this point in the history
  • Loading branch information
Varixo committed Feb 5, 2025
1 parent aed94db commit 0f4fbce
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/angry-grapes-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/core': patch
---

fix: rendering attribute value from array of classes from spread props
6 changes: 5 additions & 1 deletion packages/qwik/src/core/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -805,8 +805,10 @@ export interface ResourceResolved<T> {
// @public (undocumented)
export type ResourceReturn<T> = ResourcePending<T> | ResourceResolved<T> | ResourceRejected<T>;

// Warning: (ae-forgotten-export) The symbol "PropsProxy" needs to be exported by the entry point index.d.ts
//
// @internal (undocumented)
export const _restProps: (props: Record<string, any>, omit: string[], target?: {}) => {};
export const _restProps: (props: PropsProxy, omit: string[], target?: Props) => Props;

// @internal
export function _serialize(data: unknown[]): Promise<string>;
Expand Down Expand Up @@ -862,6 +864,8 @@ export abstract class _SharedContainer implements Container {
};
} | null, symbolToChunkResolver: SymbolToChunkResolver, writer?: StreamWriter, prepVNodeData?: (vNode: any) => void): SerializationContext;
// (undocumented)
serializeAttributeValue(key: string, value: any, styleScopedId?: string | null): string | boolean | null;
// (undocumented)
abstract setContext<T>(host: HostElement, context: ContextId<T>, value: T): void;
// (undocumented)
abstract setHostProp<T>(host: HostElement, name: string, value: T): void;
Expand Down
5 changes: 2 additions & 3 deletions packages/qwik/src/core/shared/jsx/jsx-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { QwikJSX } from './types/jsx-qwik';
import type { JSXChildren } from './types/jsx-qwik-attributes';

export type Props = Record<string, unknown>;
export type PropsProxy = { [_VAR_PROPS]: Props; [_CONST_PROPS]: Props | null };

/**
* Create a JSXNode with the properties fully split into variable and constant parts, and children
Expand Down Expand Up @@ -198,9 +199,7 @@ export function h<TYPE extends string | FunctionComponent<PROPS>, PROPS extends

export const SKIP_RENDER_TYPE = ':skipRender';

export const isPropsProxy = (
obj: any
): obj is { [_VAR_PROPS]: Props; [_CONST_PROPS]: Props | null } => {
export const isPropsProxy = (obj: any): obj is PropsProxy => {
return obj && obj[_VAR_PROPS] !== undefined;
};

Expand Down
5 changes: 5 additions & 0 deletions packages/qwik/src/core/shared/shared-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Scheduler } from './scheduler';
import { createScheduler } from './scheduler';
import { createSerializationContext, type SerializationContext } from './shared-serialization';
import type { Container, HostElement, ObjToProxyMap } from './types';
import { serializeAttribute } from './utils/styles';

/** @internal */
export abstract class _SharedContainer implements Container {
Expand Down Expand Up @@ -48,6 +49,10 @@ export abstract class _SharedContainer implements Container {
return trackSignalAndAssignHost(signal, subscriber, property, this, data);
}

serializeAttributeValue(key: string, value: any, styleScopedId?: string | null) {
return serializeAttribute(key, value, styleScopedId);
}

serializationCtxFactory(
NodeConstructor: {
new (...rest: any[]): { nodeType: number; id: string };
Expand Down
23 changes: 19 additions & 4 deletions packages/qwik/src/core/shared/utils/prop.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createPropsProxy, type Props, type PropsProxy } from '../jsx/jsx-runtime';
import { _CONST_PROPS, _VAR_PROPS } from './constants';
import { NON_SERIALIZABLE_MARKER_PREFIX, QSlotParent } from './markers';

export function isSlotProp(prop: string): boolean {
Expand All @@ -9,11 +11,24 @@ export function isParentSlotProp(prop: string): boolean {
}

/** @internal */
export const _restProps = (props: Record<string, any>, omit: string[], target = {}) => {
for (const key in props) {
export const _restProps = (props: PropsProxy, omit: string[], target: Props = {}) => {
let constPropsTarget: Props | null = null;
const constProps = props[_CONST_PROPS];
if (constProps) {
for (const key in constProps) {
if (!omit.includes(key)) {
constPropsTarget ||= {};
constPropsTarget[key] = constProps[key];
}
}
}
const varPropsTarget: Props = target;
const varProps = props[_VAR_PROPS];
for (const key in varProps) {
if (!omit.includes(key)) {
(target as any)[key] = props[key];
varPropsTarget[key] = varProps[key];
}
}
return target;

return createPropsProxy(varPropsTarget, constPropsTarget);
};
208 changes: 208 additions & 0 deletions packages/qwik/src/core/tests/attributes.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ import {
useSignal,
useStore,
Fragment as Component,
Fragment as Projection,
Fragment as Signal,
Fragment,
type PropsOf,
useComputed$,
createContextId,
useContext,
useContextProvider,
$,
Slot,
} from '@qwik.dev/core';

const debug = false; //true;
Expand Down Expand Up @@ -437,4 +444,205 @@ describe.each([
</Component>
);
});

it('should render array of classes from rest props', async () => {
const ctxId = createContextId<any>('abcd');
const TabCmp = component$<any>(({ tabId, ...props }) => {
const ctxObj = useContext(ctxId);

const computedClass = useComputed$(() => {
return ctxObj.selected.value === Number(tabId) ? 'selected' : '';
});

return (
<button
id={tabId}
onClick$={() => ctxObj.onSelect$(tabId)}
class={[props.class, computedClass.value]}
>
<Slot />
</button>
);
});

const ParentComponent = component$(() => {
const selected = useSignal(0);
const ctxObj = {
selected,
onSelect$: $((tabId: number) => {
selected.value = Number(tabId);
}),
};
useContextProvider(ctxId, ctxObj);
return (
<>
{selected.value}
<TabCmp tabId={0} class={selected.value === 0 ? 'custom' : ''}>
TAB 1
</TabCmp>
<TabCmp tabId={1} class={selected.value === 1 ? 'custom' : ''}>
TAB 2
</TabCmp>
</>
);
});

const { vNode, document } = await render(<ParentComponent />, { debug });

expect(vNode).toMatchVDOM(
<Component ssr-required>
<Fragment ssr-required>
<Signal ssr-required>0</Signal>
<Component ssr-required>
<button id="0" class="custom selected">
<Projection ssr-required>TAB 1</Projection>
</button>
</Component>
<Component ssr-required>
<button id="1" class="">
<Projection ssr-required>TAB 2</Projection>
</button>
</Component>
</Fragment>
</Component>
);

await trigger(document.body, 'button[id=1]', 'click');

expect(vNode).toMatchVDOM(
<Component ssr-required>
<Fragment ssr-required>
<Signal ssr-required>1</Signal>
<Component ssr-required>
<button id="0" class="">
<Projection ssr-required>TAB 1</Projection>
</button>
</Component>
<Component ssr-required>
<button id="1" class="custom selected">
<Projection ssr-required>TAB 2</Projection>
</button>
</Component>
</Fragment>
</Component>
);
});

describe('class attribute', () => {
it('should render class attribute', async () => {
const Cmp = component$(() => {
return <span class="test-class"></span>;
});

const { vNode } = await render(<Cmp />, { debug });
expect(vNode).toMatchVDOM(
<Component>
<span class="test-class"></span>
</Component>
);
});

it('should trim class attribute value', async () => {
const Cmp = component$(() => {
return <span class=" test-class "></span>;
});

const { vNode } = await render(<Cmp />, { debug });
expect(vNode).toMatchVDOM(
<Component>
<span class="test-class"></span>
</Component>
);
});

it('should render class attribute from signal', async () => {
const Cmp = component$(() => {
const sigValue = useSignal('testA');
return <button class={sigValue.value}></button>;
});

const { vNode } = await render(<Cmp />, { debug });
expect(vNode).toMatchVDOM(
<Component ssr-required>
<button class="testA"></button>
</Component>
);
});

it('should render class attribute from array of strings', async () => {
const Cmp = component$(() => {
return <button class={['testA', 'testB']}></button>;
});

const { vNode } = await render(<Cmp />, { debug });
expect(vNode).toMatchVDOM(
<Component ssr-required>
<button class="testA testB"></button>
</Component>
);
});

it('should render class attribute from array of mixed signals and strings', async () => {
const Cmp = component$(() => {
const sigValue = useSignal('testA');
return <button class={[sigValue.value, 'testB']}></button>;
});

const { vNode } = await render(<Cmp />, { debug });
expect(vNode).toMatchVDOM(
<Component ssr-required>
<button class="testA testB"></button>
</Component>
);
});

it('should render class attribute from objects ', async () => {
const Cmp = component$(() => {
return (
<button
class={{
testA: true,
testB: false,
}}
></button>
);
});

const { vNode } = await render(<Cmp />, { debug });
expect(vNode).toMatchVDOM(
<Component ssr-required>
<button class="testA"></button>
</Component>
);
});

it('should render class attribute from object', async () => {
const Cmp = component$(() => {
const renderClass = useSignal(true);
return (
<button
class={{
testA: true,
testB: false,
toggle: renderClass.value,
}}
onClick$={() => (renderClass.value = !renderClass.value)}
></button>
);
});

const { vNode, document } = await render(<Cmp />, { debug });
expect(vNode).toMatchVDOM(
<Component ssr-required>
<button class="testA toggle"></button>
</Component>
);
await trigger(document.body, 'button', 'click');
expect(vNode).toMatchVDOM(
<Component ssr-required>
<button class="testA"></button>
</Component>
);
});
});
});
1 change: 0 additions & 1 deletion packages/qwik/src/server/qwik-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
* file lists code which we are OK to have duplicated.
*/

export { serializeAttribute } from '../core/shared/utils/styles';
export { dangerouslySetInnerHTML } from '../core/shared/utils/markers';
export {
ELEMENT_ID,
Expand Down
3 changes: 1 addition & 2 deletions packages/qwik/src/server/ssr-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ import {
mapArray_get,
mapArray_set,
maybeThen,
serializeAttribute,
QSubscribers,
QError,
qError,
Expand Down Expand Up @@ -1187,7 +1186,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
value = QContainerValue.TEXT;
}

const serializedValue = serializeAttribute(key, value, styleScopedId);
const serializedValue = this.serializeAttributeValue(key, value, styleScopedId);

if (serializedValue != null && serializedValue !== false) {
this.write(' ');
Expand Down

0 comments on commit 0f4fbce

Please sign in to comment.