Skip to content

Commit f9ad480

Browse files
committed
fix: detect changing already streamed nodes
1 parent 4f32d06 commit f9ad480

File tree

17 files changed

+264
-56
lines changed

17 files changed

+264
-56
lines changed

packages/qwik/src/core/client/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ export const enum VNodeFlags {
7777
Element /* ****************** */ = 0b00_000001,
7878
Virtual /* ****************** */ = 0b00_000010,
7979
ELEMENT_OR_VIRTUAL_MASK /* ** */ = 0b00_000011,
80+
Text /* ********************* */ = 0b00_000100,
8081
ELEMENT_OR_TEXT_MASK /* ***** */ = 0b00_000101,
8182
TYPE_MASK /* **************** */ = 0b00_000111,
8283
INFLATED_TYPE_MASK /* ******* */ = 0b00_001111,
83-
Text /* ********************* */ = 0b00_000100,
8484
/// Extra flag which marks if a node needs to be inflated.
8585
Inflated /* ***************** */ = 0b00_001000,
8686
/// Marks if the `ensureProjectionResolved` has been called on the node.

packages/qwik/src/core/reactive-primitives/subscriber.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,6 @@ export function getSubscriber(
2929
return sub;
3030
}
3131

32-
function isSsrNode(value: any): value is ISsrNode {
32+
export function isSsrNode(value: any): value is ISsrNode {
3333
return '__brand__' in value && value.__brand__ === 'SsrNode';
3434
}

packages/qwik/src/core/shared/qrl/qrl.unit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ describe('serialization', () => {
156156
});
157157

158158
// See https://github.com/QwikDev/qwik/issues/5087#issuecomment-1707185010
159-
test.skip('should parse self-reference', () => {});
159+
test.todo('should parse self-reference');
160160

161161
test('should store resolved value', async () => {
162162
const q = qrl(() => Promise.resolve({ hi: 'hello' }), 'hi');

packages/qwik/src/core/shared/scheduler-document-position.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,11 @@ export const ssrNodeDocumentPosition = (a: ISsrNode, b: ISsrNode): -1 | 0 | 1 =>
9696
let bDepth = -1;
9797
while (a) {
9898
const ssrNode = (aSsrNodePath[++aDepth] = a);
99-
a = ssrNode.parentSsrNode!;
99+
a = ssrNode.parent!;
100100
}
101101
while (b) {
102102
const ssrNode = (bSsrNodePath[++bDepth] = b);
103-
b = ssrNode.parentSsrNode!;
103+
b = ssrNode.parent!;
104104
}
105105

106106
while (aDepth >= 0 && bDepth >= 0) {

packages/qwik/src/core/shared/scheduler.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ import {
105105
type StoreTarget,
106106
} from '../reactive-primitives/types';
107107
import { triggerEffects } from '../reactive-primitives/utils';
108-
import type { ISsrNode } from '../ssr/ssr-types';
108+
import { type ISsrNode } from '../ssr/ssr-types';
109109
import { runResource, type ResourceDescriptor } from '../use/use-resource';
110110
import {
111111
Task,
@@ -124,9 +124,8 @@ import { isServerPlatform } from './platform/platform';
124124
import { type QRLInternal } from './qrl/qrl-class';
125125
import { isQrl } from './qrl/qrl-utils';
126126
import { ssrNodeDocumentPosition, vnode_documentPosition } from './scheduler-document-position';
127-
import type { Container, HostElement } from './types';
127+
import { SsrNodeFlags, type Container, type HostElement } from './types';
128128
import { ChoreType } from './util-chore-type';
129-
import { logWarn } from './utils/log';
130129
import { QScopedStyle } from './utils/markers';
131130
import { isPromise, maybeThen, retryOnPromise, safeCall } from './utils/promises';
132131
import { addComponentStylePrefix } from './utils/scoped-styles';
@@ -136,6 +135,8 @@ import { invoke, newInvokeContext } from '../use/use-core';
136135
import { findBlockingChore, findBlockingChoreForVisible } from './scheduler-rules';
137136
import { createNextTick } from './platform/next-tick';
138137
import { AsyncComputedSignalImpl } from '../reactive-primitives/impl/async-computed-signal-impl';
138+
import { isSsrNode } from '../reactive-primitives/subscriber';
139+
import { logWarn } from './utils/log';
139140

140141
// Turn this on to get debug output of what the scheduler is doing.
141142
const DEBUG: boolean = false;
@@ -336,6 +337,31 @@ export const createScheduler = (
336337
addBlockedChore(chore, blockingChore, blockedChores);
337338
return chore;
338339
}
340+
if (isServer && chore.$host$ && isSsrNode(chore.$host$)) {
341+
const isUpdatable = !!(chore.$host$.flags & SsrNodeFlags.Updatable);
342+
343+
if (!isUpdatable) {
344+
// We are running on the server.
345+
// On server we can't schedule task for a different host!
346+
// Server is SSR, and therefore scheduling for anything but the current host
347+
// implies that things need to be re-run nad that is not supported because of streaming.
348+
const warningMessage = `A '${choreTypeToName(
349+
chore.$type$
350+
)}' chore was scheduled on a host element that has already been streamed to the client.
351+
This can lead to inconsistencies between Server-Side Rendering (SSR) and Client-Side Rendering (CSR).
352+
353+
Problematic chore:
354+
- Type: ${choreTypeToName(chore.$type$)}
355+
- Host: ${chore.$host$.toString()}
356+
- Nearest element location: ${chore.$host$.currentFile}
357+
358+
This is often caused by modifying a signal in an already rendered component during SSR.`;
359+
logWarn(warningMessage);
360+
DEBUG &&
361+
debugTrace('schedule.SKIPPED host is not updatable', chore, choreQueue, blockedChores);
362+
return chore;
363+
}
364+
}
339365
chore = sortedInsert(
340366
choreQueue,
341367
chore,
@@ -710,15 +736,6 @@ export const createScheduler = (
710736
} else {
711737
assertFalse(vnode_isVNode(aHost), 'expected aHost to be SSRNode but it is a VNode');
712738
assertFalse(vnode_isVNode(bHost), 'expected bHost to be SSRNode but it is a VNode');
713-
// we are running on the server.
714-
// On server we can't schedule task for a different host!
715-
// Server is SSR, and therefore scheduling for anything but the current host
716-
// implies that things need to be re-run nad that is not supported because of streaming.
717-
const errorMessage = `SERVER: during HTML streaming, re-running tasks on a different host is not allowed.
718-
You are attempting to change a state that has already been streamed to the client.
719-
This can lead to inconsistencies between Server-Side Rendering (SSR) and Client-Side Rendering (CSR).
720-
Problematic Node: ${aHost.toString()}`;
721-
logWarn(errorMessage);
722739
const hostDiff = ssrNodeDocumentPosition(aHost as ISsrNode, bHost as ISsrNode);
723740
if (hostDiff !== 0) {
724741
return hostDiff;
@@ -851,6 +868,25 @@ export function addBlockedChore(
851868
blockedChores.add(blockedChore);
852869
}
853870

871+
function choreTypeToName(type: ChoreType): string {
872+
return (
873+
(
874+
{
875+
[ChoreType.QRL_RESOLVE]: 'Resolve QRL',
876+
[ChoreType.RUN_QRL]: 'Run QRL',
877+
[ChoreType.TASK]: 'Task',
878+
[ChoreType.NODE_DIFF]: 'Changes diffing',
879+
[ChoreType.NODE_PROP]: 'Updating node property',
880+
[ChoreType.COMPONENT]: 'Component',
881+
[ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS]: 'Signal recompute',
882+
[ChoreType.VISIBLE]: 'Visible',
883+
[ChoreType.CLEANUP_VISIBLE]: 'Cleanup visible',
884+
[ChoreType.WAIT_FOR_QUEUE]: 'Wait for queue',
885+
} as Record<ChoreType, string>
886+
)[type] || 'Unknown: ' + type
887+
);
888+
}
889+
854890
function debugChoreTypeToString(type: ChoreType): string {
855891
return (
856892
(

packages/qwik/src/core/shared/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,7 @@ export type SerializationStrategy =
123123
// TODO: implement this in the future
124124
// 'auto' |
125125
'never' | 'always';
126+
127+
export const enum SsrNodeFlags {
128+
Updatable = 1,
129+
}

packages/qwik/src/core/ssr/ssr-types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { VNodeData } from '../../server/vnode-data';
1212
import type { Signal } from '../reactive-primitives/signal.public';
1313
import type { JSXNodeInternal } from '../shared/jsx/types/jsx-node';
1414
import type { QRL } from '../shared/qrl/qrl.public';
15+
import type { SsrNodeFlags } from '../shared/types';
1516
import type { ResourceReturnInternal } from '../use/use-resource';
1617

1718
export type SsrAttrKey = string;
@@ -25,12 +26,15 @@ export interface StreamWriter {
2526

2627
export interface ISsrNode {
2728
id: string;
28-
parentSsrNode: ISsrNode | null;
29-
vnodeData?: VNodeData;
29+
flags: SsrNodeFlags;
30+
parent: ISsrNode | null;
31+
vnodeData: VNodeData;
32+
currentFile: string | null;
3033
setProp(name: string, value: any): void;
3134
getProp(name: string): any;
3235
removeProp(name: string): void;
3336
addChild(child: ISsrNode): void;
37+
setTreeNonUpdatable(): void;
3438
}
3539

3640
/** @internal */

packages/qwik/src/core/tests/container.spec.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ describe('serializer v2', () => {
109109

110110
describe('node references', () => {
111111
// doesn't use the vnode so not serialized
112-
it.skip('should retrieve element', async () => {
112+
it('should retrieve element', async () => {
113113
const clientContainer = await withContainer((ssr) => {
114114
ssr.openElement('div', ['id', 'parent']);
115115
ssr.textNode('Hello');
@@ -512,7 +512,7 @@ describe('serializer v2', () => {
512512

513513
describe('DocumentSerializer, //////', () => {
514514
it('should serialize and deserialize', async () => {
515-
const obj = new SsrNode(null, '', -1, [], [] as any);
515+
const obj = new SsrNode(null, '', -1, [], [] as any, null);
516516
const container = await withContainer((ssr) => ssr.addRoot(obj));
517517
expect(container.$getObjectById$(0)).toEqual(container.element.ownerDocument);
518518
});

packages/qwik/src/core/tests/ssr-render.spec.tsx

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1-
import { Slot, useSignal } from '@qwik.dev/core';
1+
import {
2+
createContextId,
3+
Slot,
4+
useContext,
5+
useContextProvider,
6+
useSignal,
7+
useTask$,
8+
type Signal,
9+
} from '@qwik.dev/core';
210
import { ssrRenderToDom, trigger } from '@qwik.dev/core/testing';
3-
import { describe, expect, it } from 'vitest';
11+
import { describe, expect, it, vi } from 'vitest';
412
import { component$ } from '../shared/component.public';
513
import {
614
Fragment as Component,
@@ -10,6 +18,7 @@ import {
1018
} from '../shared/jsx/jsx-runtime';
1119
import { SSRComment, SSRRaw, SSRStream, SSRStreamBlock } from '../shared/jsx/utils.public';
1220
import { delay } from '../shared/utils/promises';
21+
import * as logUtils from '../shared/utils/log';
1322

1423
const debug = false; //true;
1524
Error.stackTraceLimit = 100;
@@ -241,4 +250,112 @@ describe('v2 ssr render', () => {
241250
);
242251
});
243252
});
253+
254+
it('should correctly detect node changes when node was already streamed', async () => {
255+
const logWarnSpy = vi.spyOn(logUtils, 'logWarn').mockImplementation(() => {});
256+
257+
const rootContextId = createContextId<RootContext>('root-context');
258+
259+
type RootContext = {
260+
isLabel: Signal<boolean>;
261+
isDescription: Signal<boolean>;
262+
};
263+
264+
const Label = component$(() => {
265+
const context = useContext(rootContextId);
266+
267+
useTask$(() => {
268+
context.isLabel.value = true;
269+
});
270+
271+
return <div>Label</div>;
272+
});
273+
274+
const Sibling = component$(() => {
275+
const context = useContext(rootContextId);
276+
277+
return (
278+
<>
279+
<p>Does Sibling know about Label? {context.isLabel.value ? 'Yes' : 'No'}</p>
280+
<p>Does Sibling know about Description? {context.isDescription.value ? 'Yes' : 'No'} </p>
281+
</>
282+
);
283+
});
284+
285+
const Description = component$(() => {
286+
const context = useContext(rootContextId);
287+
288+
useTask$(() => {
289+
context.isDescription.value = true;
290+
});
291+
292+
return <div>Description</div>;
293+
});
294+
295+
const Cmp = component$(() => {
296+
const isLabel = useSignal(false);
297+
const isDescription = useSignal(false);
298+
299+
const context: RootContext = {
300+
isLabel,
301+
isDescription,
302+
};
303+
304+
useContextProvider(rootContextId, context);
305+
306+
return (
307+
<>
308+
<Label />
309+
<Sibling />
310+
<Description />
311+
</>
312+
);
313+
});
314+
315+
await ssrRenderToDom(<Cmp />, { debug });
316+
317+
expect(logWarnSpy).toHaveBeenCalledTimes(1);
318+
});
319+
320+
it('should correctly detect attribute changes when node was already streamed', async () => {
321+
const logWarnSpy = vi.spyOn(logUtils, 'logWarn').mockImplementation(() => {});
322+
323+
const rootContextId = createContextId<RootContext>('root-context');
324+
325+
type RootContext = {
326+
isLabel: Signal<boolean>;
327+
};
328+
329+
const Label = component$(() => {
330+
const context = useContext(rootContextId);
331+
332+
useTask$(() => {
333+
context.isLabel.value = true;
334+
});
335+
336+
return <div>Label</div>;
337+
});
338+
339+
const Cmp = component$(() => {
340+
const isLabel = useSignal(false);
341+
342+
const context: RootContext = {
343+
isLabel,
344+
};
345+
346+
useContextProvider(rootContextId, context);
347+
348+
return (
349+
<>
350+
<div aria-label={context.isLabel.value ? 'Yes' : 'No'}>
351+
<Label />
352+
</div>
353+
</>
354+
);
355+
});
356+
357+
await ssrRenderToDom(<Cmp />, { debug });
358+
359+
expect(logWarnSpy).toHaveBeenCalledTimes(1);
360+
});
244361
});

packages/qwik/src/core/use/use-resource.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import { Task, TaskFlags, cleanupTask, type DescriptorBase, type Tracker } from
77

88
import type { Container, HostElement, ValueOrPromise } from '../../server/qwik-types';
99
import { clearAllEffects } from '../reactive-primitives/cleanup';
10-
import { createStore, getStoreTarget, unwrapStore } from '../reactive-primitives/impl/store';
10+
import {
11+
createStore,
12+
forceStoreEffects,
13+
getStoreTarget,
14+
unwrapStore,
15+
} from '../reactive-primitives/impl/store';
1116
import type { Signal } from '../reactive-primitives/signal.public';
1217
import { StoreFlags } from '../reactive-primitives/types';
1318
import { isSignal } from '../reactive-primitives/utils';
@@ -303,18 +308,22 @@ export const runResource = <T>(
303308
done = true;
304309
if (resolved) {
305310
done = true;
306-
resource.loading = false;
307-
resource._state = 'resolved';
308-
resource._resolved = value as T;
309-
resource._error = undefined;
311+
resourceTarget.loading = false;
312+
resourceTarget._state = 'resolved';
313+
resourceTarget._resolved = value as T;
314+
resourceTarget._error = undefined;
310315
resolve(value as T);
311316
} else {
312317
done = true;
313-
resource.loading = false;
314-
resource._state = 'rejected';
315-
resource._error = value as Error;
318+
resourceTarget.loading = false;
319+
resourceTarget._state = 'rejected';
320+
resourceTarget._error = value as Error;
316321
reject(value as Error);
317322
}
323+
324+
if (!isServerPlatform()) {
325+
forceStoreEffects(resource, '_state');
326+
}
318327
return true;
319328
}
320329
return false;

0 commit comments

Comments
 (0)