Skip to content

Commit

Permalink
fix(core): Support JSX in signals
Browse files Browse the repository at this point in the history
Fix #4966
Fix #3530
  • Loading branch information
mhevery committed Nov 27, 2023
1 parent 85552d7 commit 1f7fe61
Show file tree
Hide file tree
Showing 10 changed files with 286 additions and 73 deletions.
6 changes: 3 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@
},
{
"type": "node",
"name": "vscode-jest-tests",
"name": "vitest",
"request": "launch",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"program": "${workspaceFolder}/node_modules/jest/bin/jest.js",
"program": "${workspaceFolder}/./node_modules/vitest/vitest.mjs",
"cwd": "${workspaceFolder}",
"args": ["--runInBand", "--watchAll=false"]
"args": ["--threads=false", "packages/qwik/src/core/container/render.unit.tsx"]
}
]
}
138 changes: 138 additions & 0 deletions packages/qwik/src/core/container/render.unit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { assert, suite, test } from 'vitest';
import type { JSXNode } from '../../jsx-runtime';
import { renderToString } from '../../server/render';
import { createDocument } from '../../testing/document';
import { createDOM } from '../../testing/library';
import { component$ } from '../component/component.public';
import { _fnSignal } from '../internal';
import { useSignal } from '../use/use-signal';

suite('jsx signals', () => {
const RenderJSX = component$(() => {
const jsx = useSignal<string | JSXNode>(<span>SSR</span>);
return (
<>
<button
class="set-jsx"
onJsx$={(e: CustomEvent<JSXNode | string>) => (jsx.value = e.detail)}
/>
<div class="jsx">{jsx.value}</div>
<div class="jsx-signal">{_fnSignal((p0) => p0.value, [jsx], 'p0.value')}</div>
</>
);
});

test.skip('SSR jsx', async () => {
const output = await renderToString(<RenderJSX />, { containerTagName: 'div' });
const document = createDocument();
document.body.innerHTML = output.html;
const div = document.querySelector('.jsx')!;
assert.equal(div.innerHTML, '<span>SSR</span>');
const divSignal = document.querySelector('.jsx-signal')!;
assert.equal(divSignal.innerHTML, '<!--t=1--><span>SSR</span><!---->');
});

test('CSR basic jsx', async () => {
const { screen, render, userEvent } = await createDOM();

await render(<RenderJSX />);
const div = screen.querySelector('.jsx')!;
const divSignal = screen.querySelector('.jsx-signal')!;
assert.equal(div.innerHTML, '<span>SSR</span>');
assert.equal(divSignal.innerHTML, '<span>SSR</span>');

await userEvent('.set-jsx', 'jsx', { detail: 'text' });
assert.equal(div.innerHTML, 'text');
assert.equal(divSignal.innerHTML, 'text');

await userEvent('.set-jsx', 'jsx', { detail: <i>i</i> });
assert.equal(div.innerHTML, '<i>i</i>');
assert.equal(divSignal.innerHTML, '<i>i</i>');

await userEvent('.set-jsx', 'jsx', { detail: <b>b</b> });
assert.equal(div.innerHTML, '<b>b</b>');
assert.equal(divSignal.innerHTML, '<b>b</b>');

await userEvent('.set-jsx', 'jsx', { detail: <>v</> });
assert.equal(div.innerHTML, 'v');
assert.equal(divSignal.innerHTML, 'v');

await userEvent('.set-jsx', 'jsx', { detail: <b>b</b> });
assert.equal(div.innerHTML, '<b>b</b>');
assert.equal(divSignal.innerHTML, '<b>b</b>');

await userEvent('.set-jsx', 'jsx', { detail: 'text' });
assert.equal(div.innerHTML, 'text');
assert.equal(divSignal.innerHTML, 'text');
});

test('CSR jsx primitives', async () => {
const { screen, render, userEvent } = await createDOM();

await render(<RenderJSX />);
const div = screen.querySelector('.jsx')!;
const divSignal = screen.querySelector('.jsx-signal')!;
assert.equal(div.innerHTML, '<span>SSR</span>');
assert.equal(divSignal.innerHTML, '<span>SSR</span>');

await userEvent('.set-jsx', 'jsx', { detail: true });
assert.equal(div.innerHTML, '');
assert.equal(divSignal.innerHTML, '');

await userEvent('.set-jsx', 'jsx', { detail: 0 });
assert.equal(div.innerHTML, '0');
assert.equal(divSignal.innerHTML, '0');
});

test('CSR jsx arrays', async () => {
const { screen, render, userEvent } = await createDOM();

await render(<RenderJSX />);
const div = screen.querySelector('.jsx')!;
const divSignal = screen.querySelector('.jsx-signal')!;
assert.equal(div.innerHTML, '<span>SSR</span>');
assert.equal(divSignal.innerHTML, '<span>SSR</span>');

await userEvent('.set-jsx', 'jsx', { detail: [] });
assert.equal(div.innerHTML, '');
assert.equal(divSignal.innerHTML, '<!--qv --><!--/qv-->');

await userEvent('.set-jsx', 'jsx', { detail: ['text', <b>b</b>] });
assert.equal(div.innerHTML, 'text<b>b</b>');
assert.equal(divSignal.innerHTML, '<!--qv -->text<b>b</b><!--/qv-->');
});

test.skip('CSR jsx Promises', async () => {
// Render signals that resolves promise is not supported
const { screen, render, userEvent } = await createDOM();

await render(<RenderJSX />);
const div = screen.querySelector('.jsx')!;
const divSignal = screen.querySelector('.jsx-signal')!;
assert.equal(div.innerHTML, '<span>SSR</span>');
assert.equal(divSignal.innerHTML, '<span>SSR</span>');

await userEvent('.set-jsx', 'jsx', { detail: Promise.resolve('Test') });
assert.equal(div.innerHTML, 'Test');
assert.equal(divSignal.innerHTML, 'Test');
});

const ChildComp = component$(() => <span>ChildComp</span>);

test('CSR jsx with component', async () => {
// Render signals that resolves promise is not supported
const { screen, render, userEvent } = await createDOM();

await render(<RenderJSX />);
const div = screen.querySelector('.jsx')!;
const divSignal = screen.querySelector('.jsx-signal')!;
assert.equal(div.innerHTML, '<span>SSR</span>');
assert.equal(divSignal.innerHTML, '<span>SSR</span>');

await userEvent('.set-jsx', 'jsx', {
detail: <ChildComp />,
});
assert.equal(div.innerHTML, '<!--qv --><span>ChildComp</span><!--/qv-->');
assert.equal(divSignal.innerHTML, '<!--qv --><span>ChildComp</span><!--/qv-->');
});
});
2 changes: 1 addition & 1 deletion packages/qwik/src/core/render/dom/notify-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ const renderMarked = async (containerState: ContainerState): Promise<void> => {
}

signalOperations.forEach((op) => {
executeSignalOperation(staticCtx, op);
executeSignalOperation(rCtx, op);
});

// Add post operations
Expand Down
1 change: 1 addition & 0 deletions packages/qwik/src/core/render/dom/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export const createTemplate = (doc: Document, slotName: string) => {

export const executeDOMRender = (staticCtx: RenderStaticContext) => {
for (const op of staticCtx.$operations$) {
// PERF(misko): polymorphic execution
op.$operation$.apply(undefined, op.$args$);
}
resolveSlotProjection(staticCtx);
Expand Down
3 changes: 2 additions & 1 deletion packages/qwik/src/core/render/dom/render-dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,11 @@ export const processData = (
} else if (isJSXNode(node)) {
return processNode(node, invocationContext);
} else if (isSignal(node)) {
const newNode = new ProcessedJSXNodeImpl('#text', EMPTY_OBJ, null, EMPTY_ARRAY, 0, null);
const newNode = new ProcessedJSXNodeImpl('#signal', EMPTY_OBJ, null, EMPTY_ARRAY, 0, null);
newNode.$signal$ = node;
return newNode;
} else if (isArray(node)) {
// PERF(misko): possible place to make it faster by not creating promises unnecessarily
const output = promiseAll(node.flatMap((n) => processData(n, invocationContext)));
return maybeThen(output, (array) => array.flat(100).filter(isNotNullable));
} else if (isPromise(node)) {
Expand Down
65 changes: 51 additions & 14 deletions packages/qwik/src/core/render/dom/signals.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import type { SubscriberSignal } from '../../state/common';
import { tryGetContext } from '../../state/context';
import { getContext, tryGetContext } from '../../state/context';
import { trackSignal } from '../../use/use-core';
import { jsxToString, serializeClassWithHost, stringifyStyle } from '../execute-component';
import type { RenderStaticContext } from '../types';
import { setProperty } from './operations';
import { getVdom } from './render-dom';
import { smartSetProperty, SVG_NS } from './visitor';
import { logError } from '../../util/log';
import { serializeClassWithHost, stringifyStyle } from '../execute-component';
import type { RenderContext } from '../types';
import { insertBefore, removeNode } from './operations';
import { getVdom, processData, type ProcessedJSXNode } from './render-dom';
import type { QwikElement } from './virtual-element';
import { SVG_NS, createElm, diffVnode, getVnodeFromEl, smartSetProperty } from './visitor';
import { Virtual, JSXNodeImpl } from '../jsx/jsx-runtime';
import { isPromise } from '../../util/promises';

export const executeSignalOperation = (
staticCtx: RenderStaticContext,
operation: SubscriberSignal
) => {
export const executeSignalOperation = (rCtx: RenderContext, operation: SubscriberSignal) => {
try {
const type = operation[0];
const staticCtx = rCtx.$static$;
switch (type) {
case 1:
case 2: {
Expand Down Expand Up @@ -49,13 +51,48 @@ export const executeSignalOperation = (
}
case 3:
case 4: {
const elm: Text = operation[3] as Text;

const elm = operation[3];
if (!staticCtx.$visited$.includes(elm)) {
// assertTrue(elm.isConnected, 'text node must be connected to the dom');
staticCtx.$containerState$.$subsManager$.$clearSignal$(operation);
const value = trackSignal(operation[2], operation.slice(0, -1) as any);
return setProperty(staticCtx, elm, 'data', jsxToString(value));
const signal = operation[2];
// MISKO: I believe no `invocationContext` is OK because the JSX in signal
// has already been converted to JSX and there is nothing to execute there.
const invocationContext = undefined;
let signalValue = signal.value;
if (Array.isArray(signalValue)) {
signalValue = new JSXNodeImpl<typeof Virtual>(Virtual, {}, null, signalValue, 0, null);
}
let newVnode = processData(signalValue, invocationContext) as
| ProcessedJSXNode
| undefined;
if (isPromise(newVnode)) {
logError('Rendering promises in JSX signals is not supported');
} else {
if (newVnode === undefined) {
newVnode = processData('', invocationContext) as ProcessedJSXNode;
}
const oldVnode = getVnodeFromEl(elm);
rCtx.$cmpCtx$ = getContext(operation[1] as QwikElement, rCtx.$static$.$containerState$);
if (
oldVnode.$type$ == newVnode.$type$ &&
oldVnode.$key$ == newVnode.$key$ &&
oldVnode.$id$ == newVnode.$id$
) {
diffVnode(rCtx, oldVnode, newVnode, 0);
} else {
const promises: Promise<any>[] = []; // TODO(misko): hook this up
const oldNode = oldVnode.$elm$;
const newElm = createElm(rCtx, newVnode, 0, promises);
if (promises.length) {
logError('Rendering promises in JSX signals is not supported');
}
operation[3] = newElm;
insertBefore(rCtx.$static$, elm.parentElement!, newElm, oldNode);
oldNode && removeNode(staticCtx, oldNode);
}
}
trackSignal(operation[2], operation.slice(0, -1) as any);
}
}
}
Expand Down
Loading

0 comments on commit 1f7fe61

Please sign in to comment.