From c66de5621c075a19467988f4195d937614777b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Sun, 2 Jun 2024 06:19:50 -0700 Subject: [PATCH 01/89] chore(v2): break circular dependency in imports --- packages/qwik/src/core/index.ts | 8 +- .../qwik/src/core/qrl/qrl.public.dollar.ts | 5 + packages/qwik/src/core/qrl/qrl.public.ts | 4 - packages/qwik/src/core/state/common.ts | 4 + packages/qwik/src/core/use/use-task-dollar.ts | 102 ++++++++++++++++++ packages/qwik/src/core/use/use-task.ts | 97 ----------------- 6 files changed, 115 insertions(+), 105 deletions(-) create mode 100644 packages/qwik/src/core/qrl/qrl.public.dollar.ts create mode 100644 packages/qwik/src/core/use/use-task-dollar.ts diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index 58ab8206433..d1ca333deed 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -28,7 +28,8 @@ export type { // Internal Runtime ////////////////////////////////////////////////////////////////////////////////////////// export { $, sync$, _qrlSync, type SyncQRL } from './qrl/qrl.public'; -export { event$, eventQrl } from './qrl/qrl.public'; +export { eventQrl } from './qrl/qrl.public'; +export { event$ } from './qrl/qrl.public.dollar'; export { qrl, inlinedQrl, inlinedQrlDEV, qrlDEV } from './qrl/qrl'; export type { QRL, PropFunction, PropFnInterface } from './qrl/qrl.public'; @@ -120,9 +121,8 @@ export type { } from './use/use-task'; export type { ResourceProps, ResourceOptions } from './use/use-resource'; export { useResource$, useResourceQrl, Resource } from './use/use-resource'; -export { useTask$, useTaskQrl } from './use/use-task'; -export { useVisibleTask$, useVisibleTaskQrl } from './use/use-task'; -export { useComputed$, useComputedQrl } from './use/use-task'; +export { useTaskQrl, useVisibleTaskQrl, useComputedQrl } from './use/use-task'; +export { useComputed$, useTask$, useVisibleTask$ } from './use/use-task-dollar'; export { useErrorBoundary } from './use/use-error-boundary'; export type { ErrorBoundaryStore } from './render/error-handling'; diff --git a/packages/qwik/src/core/qrl/qrl.public.dollar.ts b/packages/qwik/src/core/qrl/qrl.public.dollar.ts new file mode 100644 index 00000000000..4d1d4aa9454 --- /dev/null +++ b/packages/qwik/src/core/qrl/qrl.public.dollar.ts @@ -0,0 +1,5 @@ +import { eventQrl } from './qrl.public'; +import { implicit$FirstArg } from '../util/implicit_dollar'; + +/** @public */ +export const event$ = implicit$FirstArg(eventQrl); diff --git a/packages/qwik/src/core/qrl/qrl.public.ts b/packages/qwik/src/core/qrl/qrl.public.ts index 22e7728c3ff..93bd20aac2e 100644 --- a/packages/qwik/src/core/qrl/qrl.public.ts +++ b/packages/qwik/src/core/qrl/qrl.public.ts @@ -1,4 +1,3 @@ -import { implicit$FirstArg } from '../util/implicit_dollar'; import { qDev, qRuntimeQrl } from '../util/qdev'; import type { QRLDev } from './qrl'; import { SYNC_QRL, createQRL } from './qrl-class'; @@ -272,9 +271,6 @@ export const eventQrl = (qrl: QRL): QRL => { return qrl; }; -/** @public */ -export const event$ = implicit$FirstArg(eventQrl); - /** @alpha */ export interface SyncQRL extends QRL { __brand__SyncQRL__: TYPE; diff --git a/packages/qwik/src/core/state/common.ts b/packages/qwik/src/core/state/common.ts index 99278bb9dc3..6b283040b91 100644 --- a/packages/qwik/src/core/state/common.ts +++ b/packages/qwik/src/core/state/common.ts @@ -28,6 +28,7 @@ import { ElementVNodeProps, type VNode, type VirtualVNode } from '../v2/client/t import { VNodeJournalOpCode, vnode_setAttr } from '../v2/client/vnode'; import { ChoreType } from '../v2/shared/scheduler'; import { isContainer2, type fixMeAny } from '../v2/shared/types'; +import { isSignal2 } from '../v2/signal-v2/v2-signal'; import { QObjectFlagsSymbol, QObjectManagerSymbol, QObjectTargetSymbol } from './constants'; import { tryGetContext } from './context'; import type { Signal } from './signal'; @@ -66,6 +67,9 @@ const _verifySerializable = (value: T, seen: Set, ctx: string, preMessag return value; } seen.add(unwrapped); + if (isSignal2(unwrapped)) { + return value; + } if (canSerialize(unwrapped)) { return value; } diff --git a/packages/qwik/src/core/use/use-task-dollar.ts b/packages/qwik/src/core/use/use-task-dollar.ts new file mode 100644 index 00000000000..d5854fedc3a --- /dev/null +++ b/packages/qwik/src/core/use/use-task-dollar.ts @@ -0,0 +1,102 @@ +import type { ReadonlySignal } from '../state/signal'; +import { implicit$FirstArg } from '../util/implicit_dollar'; +import { useComputedQrl, useTaskQrl, useVisibleTaskQrl, type ComputedFn } from './use-task'; + +interface Computed { + (qrl: ComputedFn): ReadonlySignal>; +} + +/** @public */ +export const useComputed$: Computed = implicit$FirstArg(useComputedQrl); + +// +// !!DO NOT EDIT THIS COMMENT DIRECTLY!!! +// (edit ../readme.md#useTask instead) +/** + * Reruns the `taskFn` when the observed inputs change. + * + * Use `useTask` to observe changes on a set of inputs, and then re-execute the `taskFn` when those + * inputs change. + * + * The `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` + * function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to + * rerun. + * + * @param task - Function which should be re-executed when changes to the inputs are detected + * @public + * + * ### Example + * + * The `useTask` function is used to observe the `store.count` property. Any changes to the + * `store.count` cause the `taskFn` to execute which in turn updates the `store.doubleCount` to + * the double of `store.count`. + * + * ```tsx + * const Cmp = component$(() => { + * const store = useStore({ + * count: 0, + * doubleCount: 0, + * debounced: 0, + * }); + * + * // Double count task + * useTask$(({ track }) => { + * const count = track(() => store.count); + * store.doubleCount = 2 * count; + * }); + * + * // Debouncer task + * useTask$(({ track }) => { + * const doubleCount = track(() => store.doubleCount); + * const timer = setTimeout(() => { + * store.debounced = doubleCount; + * }, 2000); + * return () => { + * clearTimeout(timer); + * }; + * }); + * return ( + *
+ *
+ * {store.count} / {store.doubleCount} + *
+ *
{store.debounced}
+ *
+ * ); + * }); + * ``` + * + * @public + * @see `Tracker` + */ +//
+export const useTask$ = /*#__PURE__*/ implicit$FirstArg(useTaskQrl); + +// +// !!DO NOT EDIT THIS COMMENT DIRECTLY!!! +// (edit ../readme.md#useVisibleTask instead) +/** + * ```tsx + * const Timer = component$(() => { + * const store = useStore({ + * count: 0, + * }); + * + * useVisibleTask$(() => { + * // Only runs in the client + * const timer = setInterval(() => { + * store.count++; + * }, 500); + * return () => { + * clearInterval(timer); + * }; + * }); + * + * return
{store.count}
; + * }); + * ``` + * + * @public + */ +//
+export const useVisibleTask$ = /*#__PURE__*/ implicit$FirstArg(useVisibleTaskQrl); diff --git a/packages/qwik/src/core/use/use-task.ts b/packages/qwik/src/core/use/use-task.ts index 1187a7dd52d..eb428f32468 100644 --- a/packages/qwik/src/core/use/use-task.ts +++ b/packages/qwik/src/core/use/use-task.ts @@ -434,10 +434,6 @@ interface ComputedQRL { (qrl: QRL>): ReadonlySignal>; } -interface Computed { - (qrl: ComputedFn): ReadonlySignal>; -} - /** @public */ export const useComputedQrl: ComputedQRL = (qrl: QRL>): Signal> => { const { val, set, iCtx, i } = useSequentialScope>>(); @@ -470,71 +466,6 @@ export const useComputedQrl: ComputedQRL = (qrl: QRL>): Signal< return signal; }; -/** @public */ -export const useComputed$: Computed = implicit$FirstArg(useComputedQrl); - -// -// !!DO NOT EDIT THIS COMMENT DIRECTLY!!! -// (edit ../readme.md#useTask instead) -/** - * Reruns the `taskFn` when the observed inputs change. - * - * Use `useTask` to observe changes on a set of inputs, and then re-execute the `taskFn` when those - * inputs change. - * - * The `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` - * function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to - * rerun. - * - * @param task - Function which should be re-executed when changes to the inputs are detected - * @public - * - * ### Example - * - * The `useTask` function is used to observe the `store.count` property. Any changes to the - * `store.count` cause the `taskFn` to execute which in turn updates the `store.doubleCount` to - * the double of `store.count`. - * - * ```tsx - * const Cmp = component$(() => { - * const store = useStore({ - * count: 0, - * doubleCount: 0, - * debounced: 0, - * }); - * - * // Double count task - * useTask$(({ track }) => { - * const count = track(() => store.count); - * store.doubleCount = 2 * count; - * }); - * - * // Debouncer task - * useTask$(({ track }) => { - * const doubleCount = track(() => store.doubleCount); - * const timer = setTimeout(() => { - * store.debounced = doubleCount; - * }, 2000); - * return () => { - * clearTimeout(timer); - * }; - * }); - * return ( - *
- *
- * {store.count} / {store.doubleCount} - *
- *
{store.debounced}
- *
- * ); - * }); - * ``` - * - * @public - * @see `Tracker` - */ -//
-export const useTask$ = /*#__PURE__*/ implicit$FirstArg(useTaskQrl); // // !!DO NOT EDIT THIS COMMENT DIRECTLY!!! @@ -601,34 +532,6 @@ export const useVisibleTaskQrl = (qrl: QRL, opts?: OnVisibleTaskOptions) } }; -// -// !!DO NOT EDIT THIS COMMENT DIRECTLY!!! -// (edit ../readme.md#useVisibleTask instead) -/** - * ```tsx - * const Timer = component$(() => { - * const store = useStore({ - * count: 0, - * }); - * - * useVisibleTask$(() => { - * // Only runs in the client - * const timer = setInterval(() => { - * store.count++; - * }, 500); - * return () => { - * clearInterval(timer); - * }; - * }); - * - * return
{store.count}
; - * }); - * ``` - * - * @public - */ -//
-export const useVisibleTask$ = /*#__PURE__*/ implicit$FirstArg(useVisibleTaskQrl); export type TaskDescriptor = DescriptorBase; From c8d1d03103ebecfb69476d5fa297f11610dcc1b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Sun, 2 Jun 2024 06:20:12 -0700 Subject: [PATCH 02/89] WIP --- .../src/core/v2/signal-v2/v2-signal.public.ts | 22 ++++++++ .../qwik/src/core/v2/signal-v2/v2-signal.ts | 41 ++++++++++++++ .../src/core/v2/signal-v2/v2-signal.unit.ts | 53 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 packages/qwik/src/core/v2/signal-v2/v2-signal.public.ts create mode 100644 packages/qwik/src/core/v2/signal-v2/v2-signal.ts create mode 100644 packages/qwik/src/core/v2/signal-v2/v2-signal.unit.ts diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.public.ts b/packages/qwik/src/core/v2/signal-v2/v2-signal.public.ts new file mode 100644 index 00000000000..8316b9c7d46 --- /dev/null +++ b/packages/qwik/src/core/v2/signal-v2/v2-signal.public.ts @@ -0,0 +1,22 @@ +import { implicit$FirstArg } from '../../util/implicit_dollar'; +import type { QRL } from '../../qrl/qrl.public'; +import { createSignal2 as _createSignal2 } from './v2-signal'; + +export interface ReadonlySignal2 { + readonly untrackedValue: T; + readonly value: T; +} + +export interface Signal2 extends ReadonlySignal2 { + untrackedValue: T; + value: T; +} + +export const createSignal2: { + (): Signal2; + (value: T): Signal2; +} = _createSignal2; + +export const createComputed2Qrl: (qrl: QRL<() => T>) => ReadonlySignal2 = _createSignal2; + +export const createComputed2$ = /*#__PURE__*/ implicit$FirstArg(createComputed2Qrl); diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.ts b/packages/qwik/src/core/v2/signal-v2/v2-signal.ts new file mode 100644 index 00000000000..5b8fe76e1b0 --- /dev/null +++ b/packages/qwik/src/core/v2/signal-v2/v2-signal.ts @@ -0,0 +1,41 @@ +import type { QRL } from '../../qrl/qrl.public'; +import type { Signal2 as ISignal2 } from './v2-signal.public'; + +export const createSignal2 = (value?: any) => { + return new Signal2(value, null); +}; + +export const createComputedSignal2 = (qrl: QRL<() => T>) => { + return new Signal2(undefined, qrl); +}; + +export const isSignal2 = (value: any): value is ISignal2 => { + return value instanceof Signal2; +}; + +class Signal2 implements ISignal2 { + public untrackedValue: T; + + /** + * Store a list of effects which are dependent on this signal. + * + * An effect is work which needs to be done when the signal changes. + */ + private $effects$: null | QRL[] = null; + + /** If this signal is computed, then compute function is stored here. */ + private $computeFn$: null | (() => T) | QRL<() => T>; + + constructor(value: T, computeFn: QRL<() => T> | null) { + this.untrackedValue = value; + this.$computeFn$ = computeFn; + } + + get value() { + return this.untrackedValue; + } + + set value(value) { + this.untrackedValue = value; + } +} diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.ts b/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.ts new file mode 100644 index 00000000000..c365c33dc0b --- /dev/null +++ b/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createComputed2$, createSignal2 } from './v2-signal.public'; + +describe('v2-signal', () => { + const log: any[] = []; + beforeEach(() => { + log.length = 0; + }); + describe('primitive', () => { + it('basic read operation', () => { + const signal = createSignal2(123); + expect(signal.value).toBe(123); + }); + + it('basic subscription operation', async () => { + const signal = createSignal2(123); + effect(() => log.push(signal.value)); + expect(log).toBe([123]); + signal.value++; + expect(log).toBe([123]); + await flushSignals(); + expect(log).toBe([123, 124]); + }); + }); + describe('computed', () => { + it('basic subscription operation', async () => { + const a = createSignal2(2); + const b = createSignal2(10); + await retry(() => { + const signal = createComputed2$(() => a.value + b.value); + effect(() => log.push(signal.value)); + expect(log).toBe([12]); + a.value++; + b.value++; + expect(log).toBe([12]); + }); + await flushSignals(); + expect(log).toBe([12, 23]); + }); + }); +}); + +function effect(fn: () => void) { + fn(); +} + +function flushSignals() { + return Promise.resolve(); +} + +function retry(fn: () => void) { + fn(); +} From e01a0fe1eac0a696dd98282fc39db5d9dd6ba296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Sun, 2 Jun 2024 10:31:39 -0700 Subject: [PATCH 03/89] WIP: trying to get computed signals to work --- packages/qwik/src/core/qrl/qrl-class.ts | 3 +- .../src/core/use/use-lexical-scope.public.ts | 6 +- packages/qwik/src/core/use/use-task.ts | 1 - .../src/core/v2/signal-v2/v2-signal.public.ts | 8 +- .../qwik/src/core/v2/signal-v2/v2-signal.ts | 130 ++++++++++++++++-- .../src/core/v2/signal-v2/v2-signal.unit.ts | 53 ------- .../src/core/v2/signal-v2/v2-signal.unit.tsx | 102 ++++++++++++++ 7 files changed, 237 insertions(+), 66 deletions(-) delete mode 100644 packages/qwik/src/core/v2/signal-v2/v2-signal.unit.ts create mode 100644 packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx diff --git a/packages/qwik/src/core/qrl/qrl-class.ts b/packages/qwik/src/core/qrl/qrl-class.ts index 258af9cab17..59addceb8a3 100644 --- a/packages/qwik/src/core/qrl/qrl-class.ts +++ b/packages/qwik/src/core/qrl/qrl-class.ts @@ -16,6 +16,7 @@ import { import { maybeThen } from '../util/promises'; import { qDev, qSerialize, qTest, seal } from '../util/qdev'; import { isArray, isFunction, type ValueOrPromise } from '../util/types'; +import { isSignal2 } from '../v2/signal-v2/v2-signal'; import type { QRLDev } from './qrl'; import type { QRL, QrlArgs, QrlReturn } from './qrl.public'; @@ -218,7 +219,7 @@ export function assertQrl(qrl: QRL): asserts qrl is QRLInternal { export function assertSignal(obj: unknown): asserts obj is SignalInternal { if (qDev) { - if (!isSignal(obj)) { + if (!isSignal(obj) && !isSignal2(obj)) { throw new Error('Not a Signal'); } } diff --git a/packages/qwik/src/core/use/use-lexical-scope.public.ts b/packages/qwik/src/core/use/use-lexical-scope.public.ts index a847b0b2f09..8cf1d0494b2 100644 --- a/packages/qwik/src/core/use/use-lexical-scope.public.ts +++ b/packages/qwik/src/core/use/use-lexical-scope.public.ts @@ -26,7 +26,11 @@ export const useLexicalScope = (): VARS => { let qrl = context.$qrl$ as QRLInternal | undefined; if (!qrl) { const el = context.$element$; - assertDefined(el, 'invoke: element must be defined inside useLexicalScope()', context); + computeTask.$qrl$assertDefined( + el, + 'invoke: element must be defined inside useLexicalScope()', + context + ); const containerElement = getWrappingContainer(el) as HTMLElement; assertDefined(containerElement, `invoke: cant find parent q:container of`, el); if (containerElement.getAttribute('q:runtime') == '2') { diff --git a/packages/qwik/src/core/use/use-task.ts b/packages/qwik/src/core/use/use-task.ts index eb428f32468..66f64c9a836 100644 --- a/packages/qwik/src/core/use/use-task.ts +++ b/packages/qwik/src/core/use/use-task.ts @@ -26,7 +26,6 @@ import { type Signal, type SignalInternal, } from '../state/signal'; -import { implicit$FirstArg } from '../util/implicit_dollar'; import { logError, logErrorAndStop } from '../util/log'; import { ComputedEvent, TaskEvent } from '../util/markers'; import { delay, isPromise, maybeThen, safeCall } from '../util/promises'; diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.public.ts b/packages/qwik/src/core/v2/signal-v2/v2-signal.public.ts index 8316b9c7d46..2bb42bf1cb4 100644 --- a/packages/qwik/src/core/v2/signal-v2/v2-signal.public.ts +++ b/packages/qwik/src/core/v2/signal-v2/v2-signal.public.ts @@ -1,6 +1,9 @@ import { implicit$FirstArg } from '../../util/implicit_dollar'; import type { QRL } from '../../qrl/qrl.public'; -import { createSignal2 as _createSignal2 } from './v2-signal'; +import { + createSignal2 as _createSignal2, + createComputedSignal2 as _createComputedSignal2, +} from './v2-signal'; export interface ReadonlySignal2 { readonly untrackedValue: T; @@ -17,6 +20,7 @@ export const createSignal2: { (value: T): Signal2; } = _createSignal2; -export const createComputed2Qrl: (qrl: QRL<() => T>) => ReadonlySignal2 = _createSignal2; +export const createComputed2Qrl: (qrl: QRL<() => T>) => ReadonlySignal2 = + _createComputedSignal2; export const createComputed2$ = /*#__PURE__*/ implicit$FirstArg(createComputed2Qrl); diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.ts b/packages/qwik/src/core/v2/signal-v2/v2-signal.ts index 5b8fe76e1b0..9ab08329c68 100644 --- a/packages/qwik/src/core/v2/signal-v2/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal-v2/v2-signal.ts @@ -1,12 +1,50 @@ +/** + * @file + * + * Signals come in two types: + * + * 1. `Signal` - A storage of data + * 2. `ComputedSignal` - A signal which is computed from other signals. + * + * ## Why is `ComputedSignal` different? + * + * - It needs to store a function which needs to re-run. + * - It is `Readonly` because it is computed. + */ + +import { assertDefined, assertFalse, assertTrue } from '../../error/assert'; +import type { QRLInternal } from '../../qrl/qrl-class'; import type { QRL } from '../../qrl/qrl.public'; +import { SubscriptionType } from '../../state/common'; +import { invoke, tryGetInvokeContext, type InvokeContext } from '../../use/use-core'; +import { Task, isTask } from '../../use/use-task'; +import { isPromise } from '../../util/promises'; +import type { VNode } from '../client/types'; +import { ChoreType } from '../shared/scheduler'; import type { Signal2 as ISignal2 } from './v2-signal.public'; +const DEBUG = true; + +/** + * Special value used to mark that a given signal needs to be computed. This is essentially a + * "marked as dirty" flag. + */ +const NEEDS_COMPUTATION: any = { + __dirty__: true, +}; + +// eslint-disable-next-line no-console +const log = (...args: any[]) => console.log(...args); + export const createSignal2 = (value?: any) => { return new Signal2(value, null); }; +// TODO(mhevery): this should not be a public API. export const createComputedSignal2 = (qrl: QRL<() => T>) => { - return new Signal2(undefined, qrl); + const signal = new Signal2(NEEDS_COMPUTATION, qrl); + signal.untrackedValue; // trigger computation + return signal; }; export const isSignal2 = (value: any): value is ISignal2 => { @@ -14,28 +52,104 @@ export const isSignal2 = (value: any): value is ISignal2 => { }; class Signal2 implements ISignal2 { - public untrackedValue: T; + private $untrackedValue$: T; /** * Store a list of effects which are dependent on this signal. * * An effect is work which needs to be done when the signal changes. + * + * 1. `Task` - A task which needs to be re-run. + * 2. `VNode` - A component or Direct DOM update. (Look at the VNode attributes to determine if it is + * a Component or VNode signal target) */ - private $effects$: null | QRL[] = null; + private $effects$: null | Array = null; /** If this signal is computed, then compute function is stored here. */ - private $computeFn$: null | (() => T) | QRL<() => T>; + private $computeQrl$: null | QRLInternal<() => T>; + private $context$: InvokeContext | undefined; + + constructor(value: T, computeTask: QRLInternal<() => T> | null) { + this.$untrackedValue$ = value; + this.$computeQrl$ = computeTask; + this.$context$ = tryGetInvokeContext(); + } - constructor(value: T, computeFn: QRL<() => T> | null) { - this.untrackedValue = value; - this.$computeFn$ = computeFn; + get untrackedValue() { + let untrackedValue = this.$untrackedValue$; + if (untrackedValue === NEEDS_COMPUTATION) { + assertDefined( + this.$computeQrl$, + 'Signal is marked as dirty, but no compute function is provided.' + ); + const computeQrl = this.$computeQrl$!; + const ctx = this.$context$; + const computedFn = computeQrl.getFn(ctx); + if (isPromise(computedFn)) { + throw computedFn; + } else { + const previousSubscriber = ctx?.$subscriber$; + try { + ctx && (ctx.$subscriber$ = this as any); + untrackedValue = (computedFn as () => T)(); + } finally { + ctx && (ctx.$subscriber$ = previousSubscriber); + } + assertFalse(isPromise(untrackedValue), 'Computed function must be synchronous.'); + this.$untrackedValue$ = untrackedValue; + } + } + assertFalse(untrackedValue === NEEDS_COMPUTATION, 'Signal is not computed.'); + return untrackedValue; } get value() { + const ctx = tryGetInvokeContext(); + const subscriber = ctx?.$subscriber$; + let target: Signal2 | Task; + if (subscriber) { + if (subscriber instanceof Signal2) { + assertDefined(subscriber.$computeQrl$, 'Expecting ComputedSignal'); + // Special case of a computed signal. + subscriber.$untrackedValue$ = NEEDS_COMPUTATION; + const resolved = subscriber.$computeQrl$.getFn(); + // TODO(mhevery): This needs to be added to the scheduler to make sure + // that we don't try to read the computed signal until it has been resolved. + // scheduler(ChoreType.QRL_RESOLVE, resolved); + target = subscriber; + DEBUG && log('Should schedule', resolved); + } else { + target = subscriber[1] as Task; + assertTrue(isTask(target), 'Invalid subscriber.'); + } + const effects = this.$effects$ || (this.$effects$ = []); + const existingIdx = effects.indexOf(target); + if (existingIdx === -1) { + DEBUG && log('Signal.subscribe', isSignal2(target) ? 'Signal2' : 'Task', target); + this.$effects$?.push(target); + } + } return this.untrackedValue; } set value(value) { - this.untrackedValue = value; + if (value !== this.untrackedValue) { + DEBUG && log('Signal.set', this.untrackedValue, '->', value); + this.$untrackedValue$ = value; + if (this.$effects$ && this.$context$) { + const scheduler = this.$context$.$container2$!.$scheduler$; + const scheduleEffect = (effect: VNode | Task | Signal2) => { + DEBUG && log(' effect', effect); + if (isTask(effect)) { + scheduler(ChoreType.TASK, effect); + } else if (effect instanceof Signal2) { + effect.$effects$?.forEach(scheduleEffect); + } else { + throw new Error('Not implemented'); + } + }; + this.$effects$.forEach(scheduleEffect); + } + } } } diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.ts b/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.ts deleted file mode 100644 index c365c33dc0b..00000000000 --- a/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createComputed2$, createSignal2 } from './v2-signal.public'; - -describe('v2-signal', () => { - const log: any[] = []; - beforeEach(() => { - log.length = 0; - }); - describe('primitive', () => { - it('basic read operation', () => { - const signal = createSignal2(123); - expect(signal.value).toBe(123); - }); - - it('basic subscription operation', async () => { - const signal = createSignal2(123); - effect(() => log.push(signal.value)); - expect(log).toBe([123]); - signal.value++; - expect(log).toBe([123]); - await flushSignals(); - expect(log).toBe([123, 124]); - }); - }); - describe('computed', () => { - it('basic subscription operation', async () => { - const a = createSignal2(2); - const b = createSignal2(10); - await retry(() => { - const signal = createComputed2$(() => a.value + b.value); - effect(() => log.push(signal.value)); - expect(log).toBe([12]); - a.value++; - b.value++; - expect(log).toBe([12]); - }); - await flushSignals(); - expect(log).toBe([12, 23]); - }); - }); -}); - -function effect(fn: () => void) { - fn(); -} - -function flushSignals() { - return Promise.resolve(); -} - -function retry(fn: () => void) { - fn(); -} diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx b/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx new file mode 100644 index 00000000000..08a84f1991a --- /dev/null +++ b/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx @@ -0,0 +1,102 @@ +import { createDocument } from '@builder.io/qwik/testing'; +import { isPromise } from 'util/types'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { QRLInternal } from '../../qrl/qrl-class'; +import type { QRL } from '../../qrl/qrl.public'; +import type { VirtualElement } from '../../render/dom/virtual-element'; +import { SubscriptionType, type Subscriber } from '../../state/common'; +import { invoke, newInvokeContext } from '../../use/use-core'; +import { Task } from '../../use/use-task'; +import { implicit$FirstArg } from '../../util/implicit_dollar'; +import { getDomContainer } from '../client/dom-container'; +import { ChoreType } from '../shared/scheduler'; +import type { Container2 } from '../shared/types'; +import { createComputed2$, createSignal2 } from './v2-signal.public'; +import type { ValueOrPromise } from '../../util/types'; + +describe('v2-signal', () => { + const log: any[] = []; + let container: Container2 = null!; + beforeEach(() => { + log.length = 0; + const document = createDocument({ html: '' }); + container = getDomContainer(document.body); + }); + + describe('primitive', () => { + it('basic read operation', async () => { + await withScheduler(() => { + const signal = createSignal2(123); + expect(signal.value).toEqual(123); + }); + }); + + it('basic subscription operation', async () => { + await withScheduler(async () => { + const signal = createSignal2(123); + expect(signal.value).toEqual(123); + await effect$(() => log.push(signal.value)); + expect(log).toEqual([123]); + signal.value++; + expect(log).toEqual([123]); + await flushSignals(); + expect(log).toEqual([123, 124]); + }); + }); + }); + + describe('computed', () => { + it.only('basic subscription operation', async () => { + await withScheduler(async () => { + const a = createSignal2(2); + const b = createSignal2(10); + await retry(() => { + const signal = createComputed2$(() => { + debugger; + return a.value + b.value; + }); + expect((signal as any).$untrackedValue$).toEqual(12); + expect(signal.value).toEqual(12); + effect$(() => log.push(signal.value)); + expect(log).toEqual([12]); + a.value++; + b.value++; + expect(log).toEqual([12]); + }); + await flushSignals(); + expect(log).toEqual([12, 23]); + }); + }); + }); + //////////////////////////////////////// + + function withScheduler(fn: () => ValueOrPromise) { + const ctx = newInvokeContext(); + ctx.$container2$ = container; + return invoke(ctx, fn); + } + + function flushSignals() { + return container.$scheduler$(ChoreType.WAIT_FOR_ALL); + } +}); + +async function effectQrl(fnQrl: QRL<() => void>) { + const element: VirtualElement = null!; + const task = new Task(0, 0, element, fnQrl as QRLInternal, undefined, null); + const subscriber: Subscriber = [SubscriptionType.HOST, task] as any; + const fn = (fnQrl as QRLInternal<() => void>).$resolveLazy$(); + if (isPromise(fn)) { + throw fn; + } else { + const ctx = newInvokeContext(); + ctx.$subscriber$ = subscriber; + invoke(ctx, fn); + } +} + +const effect$ = /*#__PURE__*/ implicit$FirstArg(effectQrl); + +function retry(fn: () => void) { + fn(); +} From 3b2289365c479d20e8959d0ab7260ef374a12b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Sun, 2 Jun 2024 12:42:42 -0700 Subject: [PATCH 04/89] WIP: tests passing --- .../qwik/src/core/v2/signal-v2/v2-signal.ts | 17 ++++++++++++----- .../src/core/v2/signal-v2/v2-signal.unit.tsx | 9 ++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.ts b/packages/qwik/src/core/v2/signal-v2/v2-signal.ts index 9ab08329c68..6b0873300a0 100644 --- a/packages/qwik/src/core/v2/signal-v2/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal-v2/v2-signal.ts @@ -15,10 +15,10 @@ import { assertDefined, assertFalse, assertTrue } from '../../error/assert'; import type { QRLInternal } from '../../qrl/qrl-class'; import type { QRL } from '../../qrl/qrl.public'; -import { SubscriptionType } from '../../state/common'; -import { invoke, tryGetInvokeContext, type InvokeContext } from '../../use/use-core'; +import { tryGetInvokeContext, type InvokeContext } from '../../use/use-core'; import { Task, isTask } from '../../use/use-task'; import { isPromise } from '../../util/promises'; +import { qDev } from '../../util/qdev'; import type { VNode } from '../client/types'; import { ChoreType } from '../shared/scheduler'; import type { Signal2 as ISignal2 } from './v2-signal.public'; @@ -42,7 +42,7 @@ export const createSignal2 = (value?: any) => { // TODO(mhevery): this should not be a public API. export const createComputedSignal2 = (qrl: QRL<() => T>) => { - const signal = new Signal2(NEEDS_COMPUTATION, qrl); + const signal = new Signal2(NEEDS_COMPUTATION, qrl as QRLInternal<() => T>); signal.untrackedValue; // trigger computation return signal; }; @@ -96,6 +96,7 @@ class Signal2 implements ISignal2 { ctx && (ctx.$subscriber$ = previousSubscriber); } assertFalse(isPromise(untrackedValue), 'Computed function must be synchronous.'); + DEBUG && log('Signal.computed', untrackedValue); this.$untrackedValue$ = untrackedValue; } } @@ -125,7 +126,7 @@ class Signal2 implements ISignal2 { const effects = this.$effects$ || (this.$effects$ = []); const existingIdx = effects.indexOf(target); if (existingIdx === -1) { - DEBUG && log('Signal.subscribe', isSignal2(target) ? 'Signal2' : 'Task', target); + DEBUG && log('Signal.subscribe', isSignal2(target) ? 'Signal2' : 'Task', String(target)); this.$effects$?.push(target); } } @@ -139,10 +140,11 @@ class Signal2 implements ISignal2 { if (this.$effects$ && this.$context$) { const scheduler = this.$context$.$container2$!.$scheduler$; const scheduleEffect = (effect: VNode | Task | Signal2) => { - DEBUG && log(' effect', effect); + DEBUG && log(' schedule.effect', String(effect)); if (isTask(effect)) { scheduler(ChoreType.TASK, effect); } else if (effect instanceof Signal2) { + effect.$untrackedValue$ = NEEDS_COMPUTATION; effect.$effects$?.forEach(scheduleEffect); } else { throw new Error('Not implemented'); @@ -153,3 +155,8 @@ class Signal2 implements ISignal2 { } } } + +qDev && + (Signal2.prototype.toString = () => { + return 'Signal2'; + }); \ No newline at end of file diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx b/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx index 08a84f1991a..dda07504c04 100644 --- a/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx +++ b/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx @@ -52,15 +52,17 @@ describe('v2-signal', () => { const b = createSignal2(10); await retry(() => { const signal = createComputed2$(() => { - debugger; return a.value + b.value; }); expect((signal as any).$untrackedValue$).toEqual(12); expect(signal.value).toEqual(12); - effect$(() => log.push(signal.value)); + effect$(() => { + console.log('TEST effect.signal', signal.value); + log.push(signal.value); + }); expect(log).toEqual([12]); a.value++; - b.value++; + b.value += 10; expect(log).toEqual([12]); }); await flushSignals(); @@ -77,6 +79,7 @@ describe('v2-signal', () => { } function flushSignals() { + console.log('flushSignals()'); return container.$scheduler$(ChoreType.WAIT_FOR_ALL); } }); From bee49ed6cb9fb196ab0345150894c6ccaece2bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Sun, 2 Jun 2024 13:08:09 -0700 Subject: [PATCH 05/89] WIP: hook up scheduler --- packages/qwik/src/core/v2/shared/scheduler.ts | 11 ++++++- .../qwik/src/core/v2/signal-v2/v2-signal.ts | 32 +++++++++++++++---- .../src/core/v2/signal-v2/v2-signal.unit.tsx | 9 ++---- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/qwik/src/core/v2/shared/scheduler.ts b/packages/qwik/src/core/v2/shared/scheduler.ts index e2826cc5722..3c3648d564e 100644 --- a/packages/qwik/src/core/v2/shared/scheduler.ts +++ b/packages/qwik/src/core/v2/shared/scheduler.ts @@ -109,6 +109,9 @@ export const enum ChoreType { /* order of elements (not encoded here) */ MICRO /* ***************** */ = 0b000_111, + /** Ensure tha the QRL promise is resolved before processing next chores in the queue */ + QRL_RESOLVE /* *********** */ = 0b000_000, + // TODO(mhevery): COMPUTED should be deleted because it is handled synchronously. COMPUTED /* ************** */ = 0b000_001, RESOURCE /* ************** */ = 0b000_010, TASK /* ****************** */ = 0b000_011, @@ -150,6 +153,12 @@ export const createScheduler = ( //////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// + function schedule( + type: ChoreType.QRL_RESOLVE, + ignore0: null, + ignore1: null, + promise: Promise + ): ValueOrPromise; function schedule(type: ChoreType.JOURNAL_FLUSH): ValueOrPromise; function schedule(type: ChoreType.WAIT_FOR_ALL): ValueOrPromise; function schedule(type: ChoreType.WAIT_FOR_COMPONENTS): ValueOrPromise; @@ -186,7 +195,7 @@ export const createScheduler = ( ///// IMPLEMENTATION ///// function schedule( type: ChoreType, - hostOrTask: HostElement | Task = null!, + hostOrTask: HostElement | Task | null = null, targetOrQrl: HostElement | QRL<(...args: any[]) => any> | null = null, payload: any = null ): ValueOrPromise { diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.ts b/packages/qwik/src/core/v2/signal-v2/v2-signal.ts index 6b0873300a0..b046995ec96 100644 --- a/packages/qwik/src/core/v2/signal-v2/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal-v2/v2-signal.ts @@ -59,14 +59,32 @@ class Signal2 implements ISignal2 { * * An effect is work which needs to be done when the signal changes. * - * 1. `Task` - A task which needs to be re-run. + * 1. `Task` - A task which needs to be re-run. For example a `useTask` or `useResource`, etc... * 2. `VNode` - A component or Direct DOM update. (Look at the VNode attributes to determine if it is * a Component or VNode signal target) + * 3. `Signal2` - A derived signal which needs to be re-computed. A derived signal gets marked as + * dirty synchronously, but the computation is lazy. + * + * `Task` and `VNode` are leaves in a tree, where as `Signal2` is a node in a tree. When + * processing a change in a signal, the leaves (`Task` and `VNode`) are scheduled for execution, + * where as the Nodes (`Signal2`) are synchronously recursed into and marked as dirty. */ private $effects$: null | Array = null; - /** If this signal is computed, then compute function is stored here. */ + /** + * If this signal is computed, then compute function is stored here. + * + * The computed functions must be executed synchronously (because of this we need to eagerly + * resolve the QRL during the mark dirty phase so that any call to it will be synchronous). ) + */ private $computeQrl$: null | QRLInternal<() => T>; + + /** + * The execution context when the signal was being created. + * + * The context contains the scheduler and the subscriber, and is used by the derived signal to + * capture dependencies. + */ private $context$: InvokeContext | undefined; constructor(value: T, computeTask: QRLInternal<() => T> | null) { @@ -113,12 +131,12 @@ class Signal2 implements ISignal2 { assertDefined(subscriber.$computeQrl$, 'Expecting ComputedSignal'); // Special case of a computed signal. subscriber.$untrackedValue$ = NEEDS_COMPUTATION; - const resolved = subscriber.$computeQrl$.getFn(); - // TODO(mhevery): This needs to be added to the scheduler to make sure - // that we don't try to read the computed signal until it has been resolved. - // scheduler(ChoreType.QRL_RESOLVE, resolved); + const qrl = subscriber.$computeQrl$!; + if (!qrl.resolved) { + const resolved = subscriber.$computeQrl$.resolve(); + this.$context$?.$container2$?.$scheduler$(ChoreType.QRL_RESOLVE, null, null, resolved); + } target = subscriber; - DEBUG && log('Should schedule', resolved); } else { target = subscriber[1] as Task; assertTrue(isTask(target), 'Invalid subscriber.'); diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx b/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx index dda07504c04..5f397b22f85 100644 --- a/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx +++ b/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx @@ -51,15 +51,10 @@ describe('v2-signal', () => { const a = createSignal2(2); const b = createSignal2(10); await retry(() => { - const signal = createComputed2$(() => { - return a.value + b.value; - }); + const signal = createComputed2$(() => a.value + b.value); expect((signal as any).$untrackedValue$).toEqual(12); expect(signal.value).toEqual(12); - effect$(() => { - console.log('TEST effect.signal', signal.value); - log.push(signal.value); - }); + effect$(() => log.push(signal.value)); expect(log).toEqual([12]); a.value++; b.value += 10; From e23b4bf47808bb265fd13999d293a081ad5b145a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Tue, 11 Jun 2024 21:50:04 -0400 Subject: [PATCH 06/89] WIP: signals keep track of effects --- packages/qwik/src/core/use/use-core.ts | 3 + .../qwik/src/core/v2/signal-v2/v2-signal.ts | 180 --------------- .../{signal-v2 => signal}/v2-signal.public.ts | 0 packages/qwik/src/core/v2/signal/v2-signal.ts | 215 ++++++++++++++++++ .../{signal-v2 => signal}/v2-signal.unit.tsx | 70 ++++-- .../src/core/v2/tests/projection.spec.tsx | 22 +- 6 files changed, 286 insertions(+), 204 deletions(-) delete mode 100644 packages/qwik/src/core/v2/signal-v2/v2-signal.ts rename packages/qwik/src/core/v2/{signal-v2 => signal}/v2-signal.public.ts (100%) create mode 100644 packages/qwik/src/core/v2/signal/v2-signal.ts rename packages/qwik/src/core/v2/{signal-v2 => signal}/v2-signal.unit.tsx (57%) diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index bb3be721cb7..256f47135e7 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -30,6 +30,7 @@ import { } from '../v2/client/vnode'; import { _getQContainerElement } from '../v2/client/dom-container'; import type { ContainerElement } from '../v2/client/types'; +import type { EffectSubscriptions } from '../v2/signal/v2-signal'; declare const document: QwikDocument; @@ -90,6 +91,7 @@ export interface InvokeContext { $waitOn$: Promise[] | undefined; /** The current subscriber for registering signal reads */ $subscriber$: Subscriber | null | undefined; + $effectSubscriber$: EffectSubscriptions | undefined; $renderCtx$: RenderContext | undefined; $locale$: string | undefined; $container2$: Container2 | undefined; @@ -211,6 +213,7 @@ export const newInvokeContext = ( $qrl$: undefined, $waitOn$: undefined, $subscriber$: undefined, + $effectSubscriber$: undefined, $renderCtx$: undefined, $locale$, $container2$: undefined, diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.ts b/packages/qwik/src/core/v2/signal-v2/v2-signal.ts deleted file mode 100644 index b046995ec96..00000000000 --- a/packages/qwik/src/core/v2/signal-v2/v2-signal.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @file - * - * Signals come in two types: - * - * 1. `Signal` - A storage of data - * 2. `ComputedSignal` - A signal which is computed from other signals. - * - * ## Why is `ComputedSignal` different? - * - * - It needs to store a function which needs to re-run. - * - It is `Readonly` because it is computed. - */ - -import { assertDefined, assertFalse, assertTrue } from '../../error/assert'; -import type { QRLInternal } from '../../qrl/qrl-class'; -import type { QRL } from '../../qrl/qrl.public'; -import { tryGetInvokeContext, type InvokeContext } from '../../use/use-core'; -import { Task, isTask } from '../../use/use-task'; -import { isPromise } from '../../util/promises'; -import { qDev } from '../../util/qdev'; -import type { VNode } from '../client/types'; -import { ChoreType } from '../shared/scheduler'; -import type { Signal2 as ISignal2 } from './v2-signal.public'; - -const DEBUG = true; - -/** - * Special value used to mark that a given signal needs to be computed. This is essentially a - * "marked as dirty" flag. - */ -const NEEDS_COMPUTATION: any = { - __dirty__: true, -}; - -// eslint-disable-next-line no-console -const log = (...args: any[]) => console.log(...args); - -export const createSignal2 = (value?: any) => { - return new Signal2(value, null); -}; - -// TODO(mhevery): this should not be a public API. -export const createComputedSignal2 = (qrl: QRL<() => T>) => { - const signal = new Signal2(NEEDS_COMPUTATION, qrl as QRLInternal<() => T>); - signal.untrackedValue; // trigger computation - return signal; -}; - -export const isSignal2 = (value: any): value is ISignal2 => { - return value instanceof Signal2; -}; - -class Signal2 implements ISignal2 { - private $untrackedValue$: T; - - /** - * Store a list of effects which are dependent on this signal. - * - * An effect is work which needs to be done when the signal changes. - * - * 1. `Task` - A task which needs to be re-run. For example a `useTask` or `useResource`, etc... - * 2. `VNode` - A component or Direct DOM update. (Look at the VNode attributes to determine if it is - * a Component or VNode signal target) - * 3. `Signal2` - A derived signal which needs to be re-computed. A derived signal gets marked as - * dirty synchronously, but the computation is lazy. - * - * `Task` and `VNode` are leaves in a tree, where as `Signal2` is a node in a tree. When - * processing a change in a signal, the leaves (`Task` and `VNode`) are scheduled for execution, - * where as the Nodes (`Signal2`) are synchronously recursed into and marked as dirty. - */ - private $effects$: null | Array = null; - - /** - * If this signal is computed, then compute function is stored here. - * - * The computed functions must be executed synchronously (because of this we need to eagerly - * resolve the QRL during the mark dirty phase so that any call to it will be synchronous). ) - */ - private $computeQrl$: null | QRLInternal<() => T>; - - /** - * The execution context when the signal was being created. - * - * The context contains the scheduler and the subscriber, and is used by the derived signal to - * capture dependencies. - */ - private $context$: InvokeContext | undefined; - - constructor(value: T, computeTask: QRLInternal<() => T> | null) { - this.$untrackedValue$ = value; - this.$computeQrl$ = computeTask; - this.$context$ = tryGetInvokeContext(); - } - - get untrackedValue() { - let untrackedValue = this.$untrackedValue$; - if (untrackedValue === NEEDS_COMPUTATION) { - assertDefined( - this.$computeQrl$, - 'Signal is marked as dirty, but no compute function is provided.' - ); - const computeQrl = this.$computeQrl$!; - const ctx = this.$context$; - const computedFn = computeQrl.getFn(ctx); - if (isPromise(computedFn)) { - throw computedFn; - } else { - const previousSubscriber = ctx?.$subscriber$; - try { - ctx && (ctx.$subscriber$ = this as any); - untrackedValue = (computedFn as () => T)(); - } finally { - ctx && (ctx.$subscriber$ = previousSubscriber); - } - assertFalse(isPromise(untrackedValue), 'Computed function must be synchronous.'); - DEBUG && log('Signal.computed', untrackedValue); - this.$untrackedValue$ = untrackedValue; - } - } - assertFalse(untrackedValue === NEEDS_COMPUTATION, 'Signal is not computed.'); - return untrackedValue; - } - - get value() { - const ctx = tryGetInvokeContext(); - const subscriber = ctx?.$subscriber$; - let target: Signal2 | Task; - if (subscriber) { - if (subscriber instanceof Signal2) { - assertDefined(subscriber.$computeQrl$, 'Expecting ComputedSignal'); - // Special case of a computed signal. - subscriber.$untrackedValue$ = NEEDS_COMPUTATION; - const qrl = subscriber.$computeQrl$!; - if (!qrl.resolved) { - const resolved = subscriber.$computeQrl$.resolve(); - this.$context$?.$container2$?.$scheduler$(ChoreType.QRL_RESOLVE, null, null, resolved); - } - target = subscriber; - } else { - target = subscriber[1] as Task; - assertTrue(isTask(target), 'Invalid subscriber.'); - } - const effects = this.$effects$ || (this.$effects$ = []); - const existingIdx = effects.indexOf(target); - if (existingIdx === -1) { - DEBUG && log('Signal.subscribe', isSignal2(target) ? 'Signal2' : 'Task', String(target)); - this.$effects$?.push(target); - } - } - return this.untrackedValue; - } - - set value(value) { - if (value !== this.untrackedValue) { - DEBUG && log('Signal.set', this.untrackedValue, '->', value); - this.$untrackedValue$ = value; - if (this.$effects$ && this.$context$) { - const scheduler = this.$context$.$container2$!.$scheduler$; - const scheduleEffect = (effect: VNode | Task | Signal2) => { - DEBUG && log(' schedule.effect', String(effect)); - if (isTask(effect)) { - scheduler(ChoreType.TASK, effect); - } else if (effect instanceof Signal2) { - effect.$untrackedValue$ = NEEDS_COMPUTATION; - effect.$effects$?.forEach(scheduleEffect); - } else { - throw new Error('Not implemented'); - } - }; - this.$effects$.forEach(scheduleEffect); - } - } - } -} - -qDev && - (Signal2.prototype.toString = () => { - return 'Signal2'; - }); \ No newline at end of file diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.public.ts b/packages/qwik/src/core/v2/signal/v2-signal.public.ts similarity index 100% rename from packages/qwik/src/core/v2/signal-v2/v2-signal.public.ts rename to packages/qwik/src/core/v2/signal/v2-signal.public.ts diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts new file mode 100644 index 00000000000..f9fcd7ec655 --- /dev/null +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -0,0 +1,215 @@ +/** + * @file + * + * Signals come in two types: + * + * 1. `Signal` - A storage of data + * 2. `ComputedSignal` - A signal which is computed from other signals. + * + * ## Why is `ComputedSignal` different? + * + * - It needs to store a function which needs to re-run. + * - It is `Readonly` because it is computed. + */ + +import { assertDefined, assertFalse, assertTrue } from '../../error/assert'; +import { isQrl, type QRLInternal } from '../../qrl/qrl-class'; +import type { QRL } from '../../qrl/qrl.public'; +import { SubscriptionProp, SubscriptionType, type Subscriber } from '../../state/common'; +import { invoke, tryGetInvokeContext, type InvokeContext } from '../../use/use-core'; +import { Task, isTask } from '../../use/use-task'; +import { isPromise } from '../../util/promises'; +import { qDev } from '../../util/qdev'; +import type { VNode } from '../client/types'; +import { ChoreType, type Scheduler } from '../shared/scheduler'; +import type { Signal2 as ISignal2 } from './v2-signal.public'; + +const DEBUG = true; + +/** + * Special value used to mark that a given signal needs to be computed. This is essentially a + * "marked as dirty" flag. + */ +const NEEDS_COMPUTATION: any = { + __dirty__: true, +}; + +// eslint-disable-next-line no-console +const log = (...args: any[]) => console.log(...args); + +export const createSignal2 = (value?: any) => { + return new Signal2(null, value, null); +}; + +// TODO(mhevery): this should not be a public API. +export const createComputedSignal2 = (qrl: QRL<() => T>) => { + if (!qrl.resolved) { + // When we are creating a signal using a use method, we need to ensure + // that the computation can be lazy and therefore we need to unsure + // that the QRL is resolved. + // When we re-create the signal from serialization (we don't create the signal + // using useMethod) it is OK to not resolve it until the graph is marked as dirty. + throw qrl.resolve(); + } + const signal = new Signal2(null, NEEDS_COMPUTATION, qrl as QRLInternal<() => T>); + return signal; +}; + +export const isSignal2 = (value: any): value is ISignal2 => { + return value instanceof Signal2; +}; + +/** + * Effect is something which needs to happen (side-effect) due to signal value change. + * + * There are three types of effects: + * + * - `Task`: `useTask`, `useVisibleTask`, `useResource` + * - `VNode`: Either a component or `` + * - `Signal2`: A derived signal which contains a computation function. + */ +type Effect = Task | VNode | Signal2; + +/** + * An effect plus a list of subscriptions effect depends on. + * + * An effect can be trigger by one or more of signal inputs. The first step of re-running an effect + * is to clear its subscriptions so that the effect can re add new set of subscriptions. In order to + * clear the subscriptions we need to store them here. + * + * (For performance reasons we also save `InvokeContext` so that we can avoid re-creating it.) + * + * Imagine you have effect such as: + * + * ``` + * function effect1() { + * console.log(signalA.value ? signalB.value : 'default'); + * } + * ``` + * + * In the above case the `signalB` needs to be unsubscribed when `signalA` is falsy. We do this by + * always clearing all of the subscriptions + * + * The `EffectSubscriptions` stores + * + * ``` + * subscription1 = [effect1, signalA, signalB]; + * ``` + * + * The `signal1` and `signal2` back references are needed to "clear" existing subscriptions. + * + * Both `signalA` as well as `signalB` will have a reference to `subscription` to the so that the + * effect can be scheduled if either `signalA` or `signalB` triggers. The `subscription1` is shared + * between the signals. + */ +type EffectSubscriptions = [Effect, InvokeContext | null, ...Signal2[]]; + +class Signal2 implements ISignal2 { + private $untrackedValue$: T; + + /** Store a list of effects which are dependent on this signal. */ + private $effects$: null | EffectSubscriptions[] = null; + + /** + * If this signal is computed, then compute function is stored here. + * + * The computed functions must be executed synchronously (because of this we need to eagerly + * resolve the QRL during the mark dirty phase so that any call to it will be synchronous). ) + */ + private $derivedFn$: null | (() => T) | QRLInternal<() => T>; + + /** Scheduler for processing the effects. */ + private $scheduler$: Scheduler | null = null; + + constructor(scheduler: Scheduler | null, value: T, computeTask: QRLInternal<() => T> | null) { + this.$scheduler$ = scheduler; + this.$untrackedValue$ = value; + this.$derivedFn$ = computeTask; + } + + get untrackedValue() { + let untrackedValue = this.$untrackedValue$; + if (untrackedValue === NEEDS_COMPUTATION) { + const computeFn = this.$derivedFn$!; + assertDefined(computeFn, 'Signal is marked as dirty, but no compute function is provided.'); + assertFalse( + isQrl(computeFn), + 'Computed signals must run sync. Expected the QRL to be resolved at this point.' + ); + const ctx = tryGetInvokeContext(); + const previousSubscriber = ctx?.$subscriber$; + try { + ctx && (ctx.$subscriber$ = this as any); + untrackedValue = invoke(ctx, computeFn) as T; + } finally { + ctx && (ctx.$subscriber$ = previousSubscriber); + } + assertFalse(isPromise(untrackedValue), 'Computed function must be synchronous.'); + DEBUG && log('Signal.computed', untrackedValue); + this.$untrackedValue$ = untrackedValue; + } + return untrackedValue; + } + + get value() { + const ctx = tryGetInvokeContext(); + if (ctx && ctx.$effectSubscriber$) { + // Create subscription only if you have invocation context. No context, no subscription skip this. + if (this.$scheduler$ === null) { + this.$scheduler$ = ctx.$container2$!.$scheduler$; + } else { + assertTrue( + ctx.$container2$!.$scheduler$ === this.$scheduler$, + 'Schedulers do not match. Did you try to use the signal across containers?' + ); + } + const effectSubscriber = ctx.$effectSubscriber$; + const effects = this.$effects$ || (this.$effects$ = []); + // Let's make sure that we have a reference to this effect. + // Adding reference is essentially adding a subscription, so if the signal + // changes we know who to notify. + ensureContains(effects, effectSubscriber); + // But when effect is scheduled in needs to be able to know which signals + // to unsubscribe from. So we need to store the reference from the effect back + // to this signal. + ensureContains(effectSubscriber, this); + } + return this.untrackedValue; + } + + set value(value) { + if (value !== this.untrackedValue) { + DEBUG && log('Signal.set', this.untrackedValue, '->', value); + this.$untrackedValue$ = value; + if (this.$effects$) { + const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { + const effect = effectSubscriptions[0]; + DEBUG && log(' schedule.effect', String(effect)); + if (isTask(effect)) { + assertDefined(this.$scheduler$, 'Scheduler must be defined.'); + this.$scheduler$(ChoreType.TASK, effect); + } else if (effect instanceof Signal2) { + effect.$untrackedValue$ = NEEDS_COMPUTATION; + effect.$effects$?.forEach(scheduleEffect); + } else { + throw new Error('Not implemented'); + } + }; + this.$effects$.forEach(scheduleEffect); + } + } + } +} + +qDev && + (Signal2.prototype.toString = () => { + return 'Signal2'; + }); + +/** Ensure the item is in array (do nothing if already there) */ +function ensureContains(array: any[], value: any) { + const idx = array.indexOf(value); + if (idx === -1) { + array.push(value); + } +} diff --git a/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx b/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx similarity index 57% rename from packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx rename to packages/qwik/src/core/v2/signal/v2-signal.unit.tsx index 5f397b22f85..e0e38955d8e 100644 --- a/packages/qwik/src/core/v2/signal-v2/v2-signal.unit.tsx +++ b/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx @@ -1,8 +1,8 @@ -import { createDocument } from '@builder.io/qwik/testing'; -import { isPromise } from 'util/types'; -import { beforeEach, describe, expect, it } from 'vitest'; -import type { QRLInternal } from '../../qrl/qrl-class'; -import type { QRL } from '../../qrl/qrl.public'; +import { createDocument, getTestPlatform } from '@builder.io/qwik/testing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { inlinedQrl } from '../../qrl/qrl'; +import { type QRLInternal } from '../../qrl/qrl-class'; +import { type QRL } from '../../qrl/qrl.public'; import type { VirtualElement } from '../../render/dom/virtual-element'; import { SubscriptionType, type Subscriber } from '../../state/common'; import { invoke, newInvokeContext } from '../../use/use-core'; @@ -11,11 +11,13 @@ import { implicit$FirstArg } from '../../util/implicit_dollar'; import { getDomContainer } from '../client/dom-container'; import { ChoreType } from '../shared/scheduler'; import type { Container2 } from '../shared/types'; -import { createComputed2$, createSignal2 } from './v2-signal.public'; -import type { ValueOrPromise } from '../../util/types'; +import { createComputed2Qrl, createSignal2 } from './v2-signal.public'; +import { isPromise } from '../../util/promises'; +import { $, type ValueOrPromise } from '@builder.io/qwik'; describe('v2-signal', () => { const log: any[] = []; + const delayMap = new Map(); let container: Container2 = null!; beforeEach(() => { log.length = 0; @@ -23,16 +25,23 @@ describe('v2-signal', () => { container = getDomContainer(document.body); }); + afterEach(async () => { + delayMap.clear(); + await container.$scheduler$(ChoreType.WAIT_FOR_ALL); + await getTestPlatform().flush(); + container = null!; + }); + describe('primitive', () => { it('basic read operation', async () => { - await withScheduler(() => { + await withContainer(() => { const signal = createSignal2(123); expect(signal.value).toEqual(123); }); }); it('basic subscription operation', async () => { - await withScheduler(async () => { + await withContainer(async () => { const signal = createSignal2(123); expect(signal.value).toEqual(123); await effect$(() => log.push(signal.value)); @@ -46,12 +55,19 @@ describe('v2-signal', () => { }); describe('computed', () => { - it.only('basic subscription operation', async () => { - await withScheduler(async () => { + it('should simulate lazy loaded QRLs', async () => { + const qrl = delay($(() => 'OK')); + expect(qrl.resolved).not.toBeDefined(); + await qrl.resolve(); + expect(qrl.resolved).toBeDefined(); + }); + + it('basic subscription operation', async () => { + await withContainer(async () => { const a = createSignal2(2); const b = createSignal2(10); await retry(() => { - const signal = createComputed2$(() => a.value + b.value); + const signal = createComputed2Qrl(delay($(() => a.value + b.value))); expect((signal as any).$untrackedValue$).toEqual(12); expect(signal.value).toEqual(12); effect$(() => log.push(signal.value)); @@ -67,7 +83,7 @@ describe('v2-signal', () => { }); //////////////////////////////////////// - function withScheduler(fn: () => ValueOrPromise) { + function withContainer(fn: () => T): T { const ctx = newInvokeContext(); ctx.$container2$ = container; return invoke(ctx, fn); @@ -77,6 +93,23 @@ describe('v2-signal', () => { console.log('flushSignals()'); return container.$scheduler$(ChoreType.WAIT_FOR_ALL); } + + /** Simulates the QRLs being lazy loaded once per test. */ + function delay(qrl: QRL<() => T>): QRLInternal<() => T> { + const iQrl = qrl as QRLInternal<() => T>; + const hash = iQrl.$symbol$; + let delayQrl = delayMap.get(hash); + if (!delayQrl) { + console.log('DELAY', hash); + delayQrl = inlinedQrl( + Promise.resolve(iQrl.resolve()), + 'd_' + iQrl.$symbol$, + iQrl.$captureRef$ as any + ) as any; + delayMap.set(hash, delayQrl); + } + return delayQrl; + } }); async function effectQrl(fnQrl: QRL<() => void>) { @@ -95,6 +128,13 @@ async function effectQrl(fnQrl: QRL<() => void>) { const effect$ = /*#__PURE__*/ implicit$FirstArg(effectQrl); -function retry(fn: () => void) { - fn(); +function retry(fn: () => T): ValueOrPromise { + try { + return fn(); + } catch (e) { + if (isPromise(e)) { + return e.then(retry.bind(null, fn)) as ValueOrPromise; + } + throw e; + } } diff --git a/packages/qwik/src/core/v2/tests/projection.spec.tsx b/packages/qwik/src/core/v2/tests/projection.spec.tsx index 09328a7d328..7e581a332a8 100644 --- a/packages/qwik/src/core/v2/tests/projection.spec.tsx +++ b/packages/qwik/src/core/v2/tests/projection.spec.tsx @@ -22,7 +22,7 @@ import { import { vnode_getNextSibling } from '../client/vnode'; import { HTML_NS, SVG_NS } from '../../util/markers'; -const debug = false; +const debug = true; /** * Below are helper components that are constant. They have to be in the top level scope so that the @@ -41,26 +41,30 @@ describe.each([ { render: ssrRenderToDom }, // { render: domRender }, // ])('$render.name: projection', ({ render }) => { - it('should render basic projection', async () => { + it.only('should render basic projection', async () => { const Child = component$(() => { return (
- + misko
); }); const Parent = component$(() => { - return parent-content; + return ( + + parent-content + + ); }); const { vNode } = await render(render-content, { debug }); expect(vNode).toMatchVDOM( - - + +
- parent-content + parent-content
-
-
+ + ); }); it('should render unused projection into template', async () => { From defd5cfca5ceaa9ff360914912e0b451b9824777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Fri, 14 Jun 2024 18:39:21 -0400 Subject: [PATCH 07/89] WIP --- packages/qwik/src/core/v2/shared/scheduler.ts | 10 ++++++++++ packages/qwik/src/core/v2/signal/v2-signal.ts | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/qwik/src/core/v2/shared/scheduler.ts b/packages/qwik/src/core/v2/shared/scheduler.ts index 3c3648d564e..f22bf641438 100644 --- a/packages/qwik/src/core/v2/shared/scheduler.ts +++ b/packages/qwik/src/core/v2/shared/scheduler.ts @@ -99,6 +99,7 @@ import { vnode_documentPosition, vnode_isVNode } from '../client/vnode'; import { vnode_diff } from '../client/vnode-diff'; import { executeComponent2 } from './component-execution'; import type { Container2, HostElement, fixMeAny } from './types'; +import type { EffectSubscriptions } from '../signal/v2-signal'; // Turn this on to get debug output of what the scheduler is doing. const DEBUG: boolean = false; @@ -296,6 +297,15 @@ export const createScheduler = ( returnValue = runComputed2(chore.$payload$ as Task, container, host); break; case ChoreType.TASK: + const payload = chore.$payload$; + if (Array.isArray(payload)) { + // This is a hack to see if the scheduling will work. + const effectSubscriber = payload as fixMeAny as EffectSubscriptions; + const effect = effectSubscriber[0]; + returnValue = runSubscriber2(effect as Task, container, host); + break; + } + // eslint-disable-next-line no-fallthrough case ChoreType.VISIBLE: returnValue = runSubscriber2(chore.$payload$ as Task, container, host); break; diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index f9fcd7ec655..c5c92456903 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -22,6 +22,7 @@ import { isPromise } from '../../util/promises'; import { qDev } from '../../util/qdev'; import type { VNode } from '../client/types'; import { ChoreType, type Scheduler } from '../shared/scheduler'; +import type { fixMeAny } from '../shared/types'; import type { Signal2 as ISignal2 } from './v2-signal.public'; const DEBUG = true; @@ -102,7 +103,7 @@ type Effect = Task | VNode | Signal2; * effect can be scheduled if either `signalA` or `signalB` triggers. The `subscription1` is shared * between the signals. */ -type EffectSubscriptions = [Effect, InvokeContext | null, ...Signal2[]]; +export type EffectSubscriptions = [Effect, InvokeContext | null, ...Signal2[]]; class Signal2 implements ISignal2 { private $untrackedValue$: T; @@ -187,7 +188,7 @@ class Signal2 implements ISignal2 { DEBUG && log(' schedule.effect', String(effect)); if (isTask(effect)) { assertDefined(this.$scheduler$, 'Scheduler must be defined.'); - this.$scheduler$(ChoreType.TASK, effect); + this.$scheduler$(ChoreType.TASK, effectSubscriptions as fixMeAny); } else if (effect instanceof Signal2) { effect.$untrackedValue$ = NEEDS_COMPUTATION; effect.$effects$?.forEach(scheduleEffect); From 50e4ba3e2e5131f400712189f6c1cc0a051a7fd7 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Sat, 15 Jun 2024 07:29:00 +0200 Subject: [PATCH 08/89] fixup --- packages/qwik/src/core/qrl/qrl-class.ts | 2 +- packages/qwik/src/core/state/common.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/qwik/src/core/qrl/qrl-class.ts b/packages/qwik/src/core/qrl/qrl-class.ts index 59addceb8a3..82e83ad8c48 100644 --- a/packages/qwik/src/core/qrl/qrl-class.ts +++ b/packages/qwik/src/core/qrl/qrl-class.ts @@ -16,7 +16,7 @@ import { import { maybeThen } from '../util/promises'; import { qDev, qSerialize, qTest, seal } from '../util/qdev'; import { isArray, isFunction, type ValueOrPromise } from '../util/types'; -import { isSignal2 } from '../v2/signal-v2/v2-signal'; +import { isSignal2 } from '../v2/signal/v2-signal'; import type { QRLDev } from './qrl'; import type { QRL, QrlArgs, QrlReturn } from './qrl.public'; diff --git a/packages/qwik/src/core/state/common.ts b/packages/qwik/src/core/state/common.ts index 6b283040b91..14a68b9fe41 100644 --- a/packages/qwik/src/core/state/common.ts +++ b/packages/qwik/src/core/state/common.ts @@ -28,7 +28,7 @@ import { ElementVNodeProps, type VNode, type VirtualVNode } from '../v2/client/t import { VNodeJournalOpCode, vnode_setAttr } from '../v2/client/vnode'; import { ChoreType } from '../v2/shared/scheduler'; import { isContainer2, type fixMeAny } from '../v2/shared/types'; -import { isSignal2 } from '../v2/signal-v2/v2-signal'; +import { isSignal2 } from '../v2/signal/v2-signal'; import { QObjectFlagsSymbol, QObjectManagerSymbol, QObjectTargetSymbol } from './constants'; import { tryGetContext } from './context'; import type { Signal } from './signal'; From c7fb41baa3678ece2d9e6cbb84f19021cdc02e02 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Sat, 15 Jun 2024 14:50:28 +0200 Subject: [PATCH 09/89] wip --- .../src/core/v2/signal/v2-signal.public.ts | 10 +- packages/qwik/src/core/v2/signal/v2-signal.ts | 226 +++++++++++++----- .../src/core/v2/signal/v2-signal.unit.tsx | 35 ++- 3 files changed, 203 insertions(+), 68 deletions(-) diff --git a/packages/qwik/src/core/v2/signal/v2-signal.public.ts b/packages/qwik/src/core/v2/signal/v2-signal.public.ts index 2bb42bf1cb4..4b266bf6065 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.public.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.public.ts @@ -15,12 +15,20 @@ export interface Signal2 extends ReadonlySignal2 { value: T; } +export interface ComputedSignal2 extends ReadonlySignal2 { + /** + * Use this to force recalculation and running subscribers, for example when the calculated value + * mutates but remains the same object. Useful for third-party libraries. + */ + force(): void; +} + export const createSignal2: { (): Signal2; (value: T): Signal2; } = _createSignal2; -export const createComputed2Qrl: (qrl: QRL<() => T>) => ReadonlySignal2 = +export const createComputed2Qrl: (qrl: QRL<() => T>) => ComputedSignal2 = _createComputedSignal2; export const createComputed2$ = /*#__PURE__*/ implicit$FirstArg(createComputed2Qrl); diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index c5c92456903..0d8fd490abe 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -16,13 +16,18 @@ import { assertDefined, assertFalse, assertTrue } from '../../error/assert'; import { isQrl, type QRLInternal } from '../../qrl/qrl-class'; import type { QRL } from '../../qrl/qrl.public'; import { SubscriptionProp, SubscriptionType, type Subscriber } from '../../state/common'; -import { invoke, tryGetInvokeContext, type InvokeContext } from '../../use/use-core'; +import { + invoke, + newInvokeContext, + tryGetInvokeContext, + type InvokeContext, +} from '../../use/use-core'; import { Task, isTask } from '../../use/use-task'; import { isPromise } from '../../util/promises'; import { qDev } from '../../util/qdev'; import type { VNode } from '../client/types'; import { ChoreType, type Scheduler } from '../shared/scheduler'; -import type { fixMeAny } from '../shared/types'; +import type { Container2, fixMeAny } from '../shared/types'; import type { Signal2 as ISignal2 } from './v2-signal.public'; const DEBUG = true; @@ -39,10 +44,9 @@ const NEEDS_COMPUTATION: any = { const log = (...args: any[]) => console.log(...args); export const createSignal2 = (value?: any) => { - return new Signal2(null, value, null); + return new Signal2(undefined, value); }; -// TODO(mhevery): this should not be a public API. export const createComputedSignal2 = (qrl: QRL<() => T>) => { if (!qrl.resolved) { // When we are creating a signal using a use method, we need to ensure @@ -52,8 +56,7 @@ export const createComputedSignal2 = (qrl: QRL<() => T>) => { // using useMethod) it is OK to not resolve it until the graph is marked as dirty. throw qrl.resolve(); } - const signal = new Signal2(null, NEEDS_COMPUTATION, qrl as QRLInternal<() => T>); - return signal; + return new ComputedSignal2(null, qrl as QRLInternal<() => T>); }; export const isSignal2 = (value: any): value is ISignal2 => { @@ -106,66 +109,44 @@ type Effect = Task | VNode | Signal2; export type EffectSubscriptions = [Effect, InvokeContext | null, ...Signal2[]]; class Signal2 implements ISignal2 { - private $untrackedValue$: T; + protected $untrackedValue$: T; /** Store a list of effects which are dependent on this signal. */ - private $effects$: null | EffectSubscriptions[] = null; + // TODO perf: use a set? + protected $effects$: null | EffectSubscriptions[] = null; - /** - * If this signal is computed, then compute function is stored here. - * - * The computed functions must be executed synchronously (because of this we need to eagerly - * resolve the QRL during the mark dirty phase so that any call to it will be synchronous). ) - */ - private $derivedFn$: null | (() => T) | QRLInternal<() => T>; - - /** Scheduler for processing the effects. */ - private $scheduler$: Scheduler | null = null; + /** Container to get the scheduler and call the computation. */ + protected $container$: Container2 | null = null; - constructor(scheduler: Scheduler | null, value: T, computeTask: QRLInternal<() => T> | null) { - this.$scheduler$ = scheduler; + constructor(container: Container2 | null, value: T) { + this.$container$ = container; this.$untrackedValue$ = value; - this.$derivedFn$ = computeTask; } get untrackedValue() { - let untrackedValue = this.$untrackedValue$; - if (untrackedValue === NEEDS_COMPUTATION) { - const computeFn = this.$derivedFn$!; - assertDefined(computeFn, 'Signal is marked as dirty, but no compute function is provided.'); - assertFalse( - isQrl(computeFn), - 'Computed signals must run sync. Expected the QRL to be resolved at this point.' - ); - const ctx = tryGetInvokeContext(); - const previousSubscriber = ctx?.$subscriber$; - try { - ctx && (ctx.$subscriber$ = this as any); - untrackedValue = invoke(ctx, computeFn) as T; - } finally { - ctx && (ctx.$subscriber$ = previousSubscriber); - } - assertFalse(isPromise(untrackedValue), 'Computed function must be synchronous.'); - DEBUG && log('Signal.computed', untrackedValue); - this.$untrackedValue$ = untrackedValue; - } - return untrackedValue; + return this.$untrackedValue$; } get value() { const ctx = tryGetInvokeContext(); + // If no context or no subscriptions, skip this. if (ctx && ctx.$effectSubscriber$) { - // Create subscription only if you have invocation context. No context, no subscription skip this. - if (this.$scheduler$ === null) { - this.$scheduler$ = ctx.$container2$!.$scheduler$; + if (this.$container$ === null) { + assertTrue(!!ctx.$container2$, 'container should be in context '); + // Grab the container now we have access to it + this.$container$ = ctx.$container2$!; } else { - assertTrue( - ctx.$container2$!.$scheduler$ === this.$scheduler$, - 'Schedulers do not match. Did you try to use the signal across containers?' + console.log( + 'Signal2', + ctx, + this.$container$, + ctx.$container2$, + this.$container$ === ctx.$container2$ ); + assertTrue(ctx.$container2$ === this.$container$, 'Do not use signals across containers'); } const effectSubscriber = ctx.$effectSubscriber$; - const effects = this.$effects$ || (this.$effects$ = []); + const effects = (this.$effects$ ||= []); // Let's make sure that we have a reference to this effect. // Adding reference is essentially adding a subscription, so if the signal // changes we know who to notify. @@ -179,27 +160,48 @@ class Signal2 implements ISignal2 { } set value(value) { - if (value !== this.untrackedValue) { - DEBUG && log('Signal.set', this.untrackedValue, '->', value); + if (value !== this.$untrackedValue$) { + DEBUG && log('Signal.set', this.$untrackedValue$, '->', value); this.$untrackedValue$ = value; - if (this.$effects$) { - const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { - const effect = effectSubscriptions[0]; - DEBUG && log(' schedule.effect', String(effect)); - if (isTask(effect)) { - assertDefined(this.$scheduler$, 'Scheduler must be defined.'); - this.$scheduler$(ChoreType.TASK, effectSubscriptions as fixMeAny); - } else if (effect instanceof Signal2) { - effect.$untrackedValue$ = NEEDS_COMPUTATION; - effect.$effects$?.forEach(scheduleEffect); - } else { - throw new Error('Not implemented'); - } - }; - this.$effects$.forEach(scheduleEffect); + this.$triggerEffects$(); + } + } + + protected $triggerEffects$() { + log('meep', this.$effects$, !!this.$container$); + if (!this.$effects$?.length || !this.$container$) { + return; + } + const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { + const effect = effectSubscriptions[0]; + DEBUG && log(' schedule.effect', String(effect)); + if (isTask(effect)) { + const scheduler = this.$container$!.$scheduler$; + assertDefined(scheduler, 'Scheduler must be defined.'); + scheduler(ChoreType.TASK, effectSubscriptions as fixMeAny); + } else if (effect instanceof ComputedSignal2) { + effect.$invalidate$(); + } else { + throw new Error('Not implemented'); } + }; + this.$effects$.forEach(scheduleEffect); + this.$effects$.length = 0; + DEBUG && log('done scheduling'); + } + + // prevent accidental use as value + valueOf() { + if (qDev) { + throw new TypeError('Cannot coerce a Signal, use `.value` instead'); } } + toString() { + return `[Signal ${String(this.value)}]`; + } + toJSON() { + return { value: this.value }; + } } qDev && @@ -214,3 +216,95 @@ function ensureContains(array: any[], value: any) { array.push(value); } } + +/** + * A signal which is computed from other signals. + * + * The value is available synchronously, but the computation is done lazily. + */ +class ComputedSignal2 extends Signal2 { + /** + * The compute function is stored here. + * + * The computed functions must be executed synchronously (because of this we need to eagerly + * resolve the QRL during the mark dirty phase so that any call to it will be synchronous). ) + */ + $computeQrl$: QRLInternal<() => T>; + // We need a separate flag to know when the computation needs running because + // we need the old value to know if effects need running after computation + $invalid$: boolean = true; + + constructor(container: Container2 | null, computeTask: QRLInternal<() => T> | null) { + assertDefined(computeTask, 'compute QRL must be provided'); + // The value is used for comparison when signals trigger, which can only happen + // when it was calculated before. Therefore we can pass whatever we like. + super(container, NEEDS_COMPUTATION); + this.$computeQrl$ = computeTask; + } + + $invalidate$() { + this.$invalid$ = true; + if (!this.$effects$?.length) { + return; + } + // We should only call subscribers if the calculation actually changed. + // Therefore, we need to calculate the value now. + // TODO move this calculation to the beginning of the next tick, add chores to that tick if necessary. New chore type? + if (this.$computeIfNeeded$()) { + this.$triggerEffects$(); + } + } + + /** + * Use this to force running subscribers, for example when the calculated value has mutated but + * remained the same object + */ + force() { + this.$invalid$ = true; + this.$triggerEffects$(); + } + + get untrackedValue() { + this.$computeIfNeeded$(); + return this.$untrackedValue$; + } + + private $computeIfNeeded$() { + if (!this.$invalid$) { + return false; + } + const computeQrl = this.$computeQrl$; + assertDefined( + computeQrl.resolved, + 'Computed signals must run sync. Expected the QRL to be resolved at this point.' + ); + + // TODO locale (ideally move to global state) + const ctx = newInvokeContext(); + ctx.$effectSubscriber$ = [this, null]; + ctx.$container2$ = this.$container$!; + const untrackedValue = computeQrl.getFn(ctx)() as T; + assertFalse(isPromise(untrackedValue), 'Computed function must be synchronous.'); + DEBUG && log('Signal.$compute$', untrackedValue); + this.$invalid$ = false; + + const didChange = untrackedValue !== this.$untrackedValue$; + this.$untrackedValue$ = untrackedValue; + + return didChange; + } + + // Getters don't get inherited + get value() { + return super.value; + } + + set value(_: any) { + throw new TypeError('ComputedSignal is read-only'); + } +} + +qDev && + (ComputedSignal2.prototype.toString = () => { + return 'ComputedSignal2'; + }); diff --git a/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx b/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx index e0e38955d8e..35d27ba49a2 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx +++ b/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx @@ -68,18 +68,51 @@ describe('v2-signal', () => { const b = createSignal2(10); await retry(() => { const signal = createComputed2Qrl(delay($(() => a.value + b.value))); - expect((signal as any).$untrackedValue$).toEqual(12); + expect((signal as any).$untrackedValue$).not.toEqual(12); + // This won't register a subscriber because there isn't any, + // but it will update the value and store the container. expect(signal.value).toEqual(12); + expect((signal as any).$untrackedValue$).toEqual(12); effect$(() => log.push(signal.value)); expect(log).toEqual([12]); a.value++; b.value += 10; + // effects must run async expect(log).toEqual([12]); }); await flushSignals(); expect(log).toEqual([12, 23]); }); }); + // using .only because otherwise there's a function-not-the-same issue + it.only('force', () => + withContainer(async () => { + const obj = { count: 0 }; + const qrl = delay( + $(() => { + obj.count++; + return obj; + }) + ); + await qrl.resolve(); + const computed = createComputed2Qrl(qrl); + expect(computed.value).toBe(obj); + expect(obj.count).toBe(1); + effect$((v) => console.log('effect', v) || log.push(computed.value.count)); + await flushSignals(); + expect(log).toEqual([1]); + expect(obj.count).toBe(1); + // mark dirty but value remains shallow same after calc + (computed as any).$invalid$ = true; + computed.value.count; + await flushSignals(); + expect(log).toEqual([1]); + expect(obj.count).toBe(2); + // force recalculation+notify + computed.force(); + await flushSignals(); + expect(log).toEqual([1, 3]); + })); }); //////////////////////////////////////// From 61220b4b62d5b8979730dc6435fbcbc5c2b09cac Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Sat, 15 Jun 2024 16:13:27 +0200 Subject: [PATCH 10/89] wip fix tests some more --- packages/qwik/src/core/v2/signal/v2-signal.ts | 38 +++++----- .../src/core/v2/signal/v2-signal.unit.tsx | 71 ++++++++++--------- 2 files changed, 56 insertions(+), 53 deletions(-) diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index 0d8fd490abe..7b84ea23aa4 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -44,7 +44,7 @@ const NEEDS_COMPUTATION: any = { const log = (...args: any[]) => console.log(...args); export const createSignal2 = (value?: any) => { - return new Signal2(undefined, value); + return new Signal2(null, value); }; export const createComputedSignal2 = (qrl: QRL<() => T>) => { @@ -129,32 +129,29 @@ class Signal2 implements ISignal2 { get value() { const ctx = tryGetInvokeContext(); - // If no context or no subscriptions, skip this. - if (ctx && ctx.$effectSubscriber$) { + if (ctx) { if (this.$container$ === null) { assertTrue(!!ctx.$container2$, 'container should be in context '); // Grab the container now we have access to it this.$container$ = ctx.$container2$!; } else { - console.log( - 'Signal2', - ctx, - this.$container$, - ctx.$container2$, - this.$container$ === ctx.$container2$ + assertTrue( + !ctx.$container2$ || ctx.$container2$ === this.$container$, + 'Do not use signals across containers' ); - assertTrue(ctx.$container2$ === this.$container$, 'Do not use signals across containers'); } - const effectSubscriber = ctx.$effectSubscriber$; - const effects = (this.$effects$ ||= []); - // Let's make sure that we have a reference to this effect. - // Adding reference is essentially adding a subscription, so if the signal - // changes we know who to notify. - ensureContains(effects, effectSubscriber); - // But when effect is scheduled in needs to be able to know which signals - // to unsubscribe from. So we need to store the reference from the effect back - // to this signal. - ensureContains(effectSubscriber, this); + if (ctx.$effectSubscriber$) { + const effectSubscriber = ctx.$effectSubscriber$; + const effects = (this.$effects$ ||= []); + // Let's make sure that we have a reference to this effect. + // Adding reference is essentially adding a subscription, so if the signal + // changes we know who to notify. + ensureContains(effects, effectSubscriber); + // But when effect is scheduled in needs to be able to know which signals + // to unsubscribe from. So we need to store the reference from the effect back + // to this signal. + ensureContains(effectSubscriber, this); + } } return this.untrackedValue; } @@ -168,7 +165,6 @@ class Signal2 implements ISignal2 { } protected $triggerEffects$() { - log('meep', this.$effects$, !!this.$container$); if (!this.$effects$?.length || !this.$container$) { return; } diff --git a/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx b/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx index 35d27ba49a2..f10e03172f8 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx +++ b/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx @@ -4,8 +4,7 @@ import { inlinedQrl } from '../../qrl/qrl'; import { type QRLInternal } from '../../qrl/qrl-class'; import { type QRL } from '../../qrl/qrl.public'; import type { VirtualElement } from '../../render/dom/virtual-element'; -import { SubscriptionType, type Subscriber } from '../../state/common'; -import { invoke, newInvokeContext } from '../../use/use-core'; +import { invoke, newInvokeContext, tryGetInvokeContext } from '../../use/use-core'; import { Task } from '../../use/use-task'; import { implicit$FirstArg } from '../../util/implicit_dollar'; import { getDomContainer } from '../client/dom-container'; @@ -14,6 +13,7 @@ import type { Container2 } from '../shared/types'; import { createComputed2Qrl, createSignal2 } from './v2-signal.public'; import { isPromise } from '../../util/promises'; import { $, type ValueOrPromise } from '@builder.io/qwik'; +import type { EffectSubscriptions } from './v2-signal'; describe('v2-signal', () => { const log: any[] = []; @@ -62,7 +62,7 @@ describe('v2-signal', () => { expect(qrl.resolved).toBeDefined(); }); - it('basic subscription operation', async () => { + it.only('basic subscription operation', async () => { await withContainer(async () => { const a = createSignal2(2); const b = createSignal2(10); @@ -85,31 +85,33 @@ describe('v2-signal', () => { }); }); // using .only because otherwise there's a function-not-the-same issue - it.only('force', () => + it('force', () => withContainer(async () => { const obj = { count: 0 }; - const qrl = delay( - $(() => { - obj.count++; - return obj; - }) - ); - await qrl.resolve(); - const computed = createComputed2Qrl(qrl); - expect(computed.value).toBe(obj); - expect(obj.count).toBe(1); - effect$((v) => console.log('effect', v) || log.push(computed.value.count)); - await flushSignals(); - expect(log).toEqual([1]); - expect(obj.count).toBe(1); - // mark dirty but value remains shallow same after calc - (computed as any).$invalid$ = true; - computed.value.count; - await flushSignals(); - expect(log).toEqual([1]); - expect(obj.count).toBe(2); - // force recalculation+notify - computed.force(); + await retry(async () => { + const computed = createComputed2Qrl( + delay( + $(() => { + obj.count++; + return obj; + }) + ) + ); + expect(computed.value).toBe(obj); + expect(obj.count).toBe(1); + effect$(() => log.push(computed.value.count)); + await flushSignals(); + expect(log).toEqual([1]); + expect(obj.count).toBe(1); + // mark dirty but value remains shallow same after calc + (computed as any).$invalid$ = true; + computed.value.count; + await flushSignals(); + expect(log).toEqual([1]); + expect(obj.count).toBe(2); + // force recalculation+notify + computed.force(); + }); await flushSignals(); expect(log).toEqual([1, 3]); })); @@ -148,25 +150,30 @@ describe('v2-signal', () => { async function effectQrl(fnQrl: QRL<() => void>) { const element: VirtualElement = null!; const task = new Task(0, 0, element, fnQrl as QRLInternal, undefined, null); - const subscriber: Subscriber = [SubscriptionType.HOST, task] as any; + const subscriber: EffectSubscriptions = [task, null]; const fn = (fnQrl as QRLInternal<() => void>).$resolveLazy$(); if (isPromise(fn)) { throw fn; } else { const ctx = newInvokeContext(); - ctx.$subscriber$ = subscriber; - invoke(ctx, fn); + ctx.$effectSubscriber$ = subscriber; + try { + invoke(ctx, fn); + } catch (e) { + console.error('effect$ failed', fn.toString(), e); + throw e; + } } } const effect$ = /*#__PURE__*/ implicit$FirstArg(effectQrl); -function retry(fn: () => T): ValueOrPromise { +function retry(fn: () => T, ctx = tryGetInvokeContext()): ValueOrPromise { try { - return fn(); + return invoke(ctx, fn); } catch (e) { if (isPromise(e)) { - return e.then(retry.bind(null, fn)) as ValueOrPromise; + return e.then(retry.bind(null, fn, ctx)) as ValueOrPromise; } throw e; } From e90972f80b83f8336970e8e3428608c750f49deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Sat, 29 Jun 2024 13:55:07 -0700 Subject: [PATCH 11/89] WIP --- packages/qwik/src/core/debug.ts | 16 +++ packages/qwik/src/core/qrl/qrl-class.ts | 2 +- packages/qwik/src/core/state/common.ts | 5 +- packages/qwik/src/core/v2/shared/scheduler.ts | 8 +- packages/qwik/src/core/v2/signal/v2-signal.ts | 133 +++++++++--------- .../src/core/v2/signal/v2-signal.unit.tsx | 128 +++++++++-------- 6 files changed, 158 insertions(+), 134 deletions(-) create mode 100644 packages/qwik/src/core/debug.ts diff --git a/packages/qwik/src/core/debug.ts b/packages/qwik/src/core/debug.ts new file mode 100644 index 00000000000..e6e8b9677bb --- /dev/null +++ b/packages/qwik/src/core/debug.ts @@ -0,0 +1,16 @@ +import { isQrl } from "../server/prefetch-strategy"; +import { isTask } from "./use/use-task"; +import { isSignal2 } from "./v2/signal/v2-signal"; + +export function qwikDebugToString(obj: any): any { + if (Array.isArray(obj)) { + return obj.map(qwikDebugToString); + } else if (isTask(obj)) { + return `Task(${qwikDebugToString(obj.$qrl$)})` + } else if (isQrl(obj)) { + return `Qrl(${obj.$symbol$})` + } else if (isSignal2(obj)) { + return String(obj) + } + return obj; +} \ No newline at end of file diff --git a/packages/qwik/src/core/qrl/qrl-class.ts b/packages/qwik/src/core/qrl/qrl-class.ts index 82e83ad8c48..0b21742a503 100644 --- a/packages/qwik/src/core/qrl/qrl-class.ts +++ b/packages/qwik/src/core/qrl/qrl-class.ts @@ -60,7 +60,7 @@ export type QRLInternalMethods = { ): TYPE extends (...args: any) => any ? (...args: Parameters) => ValueOrPromise> : // unknown so we allow assigning function QRLs to any - unknown; + unknown; $setContainer$(containerEl: Element | undefined): Element | undefined; $resolveLazy$(containerEl?: Element): ValueOrPromise; diff --git a/packages/qwik/src/core/state/common.ts b/packages/qwik/src/core/state/common.ts index 14a68b9fe41..8a3ee975b6c 100644 --- a/packages/qwik/src/core/state/common.ts +++ b/packages/qwik/src/core/state/common.ts @@ -288,9 +288,8 @@ export const serializeSubscription = (sub: Subscriptions, getObjId: GetObjID) => } if (type <= SubscriptionType.PROP_MUTABLE) { key = sub[SubscriptionProp.ELEMENT_PROP]; - base += ` ${signalID} ${must(getObjId(sub[SubscriptionProp.ELEMENT]))} ${ - sub[SubscriptionProp.ELEMENT_PROP] - }`; + base += ` ${signalID} ${must(getObjId(sub[SubscriptionProp.ELEMENT]))} ${sub[SubscriptionProp.ELEMENT_PROP] + }`; } else if (type <= SubscriptionType.TEXT_MUTABLE) { key = sub.length > SubscriptionProp.ELEMENT_PROP ? sub[SubscriptionProp.ELEMENT_PROP] : undefined; diff --git a/packages/qwik/src/core/v2/shared/scheduler.ts b/packages/qwik/src/core/v2/shared/scheduler.ts index f22bf641438..9772896b024 100644 --- a/packages/qwik/src/core/v2/shared/scheduler.ts +++ b/packages/qwik/src/core/v2/shared/scheduler.ts @@ -99,10 +99,10 @@ import { vnode_documentPosition, vnode_isVNode } from '../client/vnode'; import { vnode_diff } from '../client/vnode-diff'; import { executeComponent2 } from './component-execution'; import type { Container2, HostElement, fixMeAny } from './types'; -import type { EffectSubscriptions } from '../signal/v2-signal'; +import { EffectSubscriptionsProp, type EffectSubscriptions } from '../signal/v2-signal'; // Turn this on to get debug output of what the scheduler is doing. -const DEBUG: boolean = false; +const DEBUG: boolean = true; export const enum ChoreType { /// MASKS defining three levels of sorting @@ -301,7 +301,7 @@ export const createScheduler = ( if (Array.isArray(payload)) { // This is a hack to see if the scheduling will work. const effectSubscriber = payload as fixMeAny as EffectSubscriptions; - const effect = effectSubscriber[0]; + const effect = effectSubscriber[EffectSubscriptionsProp.EFFECT]; returnValue = runSubscriber2(effect as Task, container, host); break; } @@ -499,7 +499,7 @@ function debugTrace( if (arg) { lines.push( ' arg: ' + - ('$type$' in arg ? debugChoreToString(arg as Chore) : String(arg).replaceAll(/\n.*/gim, '')) + ('$type$' in arg ? debugChoreToString(arg as Chore) : String(arg).replaceAll(/\n.*/gim, '')) ); } if (currentChore) { diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index 7b84ea23aa4..303e6643fbe 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -12,22 +12,20 @@ * - It is `Readonly` because it is computed. */ +import { qwikDebugToString } from '../../debug'; import { assertDefined, assertFalse, assertTrue } from '../../error/assert'; -import { isQrl, type QRLInternal } from '../../qrl/qrl-class'; +import { type QRLInternal } from '../../qrl/qrl-class'; import type { QRL } from '../../qrl/qrl.public'; -import { SubscriptionProp, SubscriptionType, type Subscriber } from '../../state/common'; import { - invoke, - newInvokeContext, tryGetInvokeContext, - type InvokeContext, + type InvokeContext } from '../../use/use-core'; -import { Task, isTask } from '../../use/use-task'; +import { Task, TaskFlags, isTask } from '../../use/use-task'; import { isPromise } from '../../util/promises'; import { qDev } from '../../util/qdev'; import type { VNode } from '../client/types'; import { ChoreType, type Scheduler } from '../shared/scheduler'; -import type { Container2, fixMeAny } from '../shared/types'; +import type { fixMeAny } from '../shared/types'; import type { Signal2 as ISignal2 } from './v2-signal.public'; const DEBUG = true; @@ -41,14 +39,15 @@ const NEEDS_COMPUTATION: any = { }; // eslint-disable-next-line no-console -const log = (...args: any[]) => console.log(...args); +const log = (...args: any[]) => console.log('SIGNAL', ...(args).map(qwikDebugToString)); export const createSignal2 = (value?: any) => { return new Signal2(null, value); }; export const createComputedSignal2 = (qrl: QRL<() => T>) => { - if (!qrl.resolved) { + const resolved = qrl.resolved; + if (!resolved) { // When we are creating a signal using a use method, we need to ensure // that the computation can be lazy and therefore we need to unsure // that the QRL is resolved. @@ -107,6 +106,10 @@ type Effect = Task | VNode | Signal2; * between the signals. */ export type EffectSubscriptions = [Effect, InvokeContext | null, ...Signal2[]]; +export const enum EffectSubscriptionsProp { + EFFECT = 0, + CONTEXT = 1, +} class Signal2 implements ISignal2 { protected $untrackedValue$: T; @@ -115,11 +118,10 @@ class Signal2 implements ISignal2 { // TODO perf: use a set? protected $effects$: null | EffectSubscriptions[] = null; - /** Container to get the scheduler and call the computation. */ - protected $container$: Container2 | null = null; + protected $scheduler$: Scheduler | null = null; - constructor(container: Container2 | null, value: T) { - this.$container$ = container; + constructor(scheduler: Scheduler | null, value: T) { + this.$scheduler$ = scheduler; this.$untrackedValue$ = value; } @@ -130,18 +132,18 @@ class Signal2 implements ISignal2 { get value() { const ctx = tryGetInvokeContext(); if (ctx) { - if (this.$container$ === null) { + if (this.$scheduler$ === null) { assertTrue(!!ctx.$container2$, 'container should be in context '); // Grab the container now we have access to it - this.$container$ = ctx.$container2$!; + this.$scheduler$ = ctx.$container2$!.$scheduler$; } else { assertTrue( - !ctx.$container2$ || ctx.$container2$ === this.$container$, + !ctx.$container2$?.$scheduler$ || ctx.$container2$?.$scheduler$ === this.$scheduler$, 'Do not use signals across containers' ); } - if (ctx.$effectSubscriber$) { - const effectSubscriber = ctx.$effectSubscriber$; + const effectSubscriber = ctx.$effectSubscriber$; + if (effectSubscriber) { const effects = (this.$effects$ ||= []); // Let's make sure that we have a reference to this effect. // Adding reference is essentially adding a subscription, so if the signal @@ -151,6 +153,7 @@ class Signal2 implements ISignal2 { // to unsubscribe from. So we need to store the reference from the effect back // to this signal. ensureContains(effectSubscriber, this); + DEBUG && log("read->sub", this.$untrackedValue$, effectSubscriber[EffectSubscriptionsProp.EFFECT]) } } return this.untrackedValue; @@ -165,24 +168,26 @@ class Signal2 implements ISignal2 { } protected $triggerEffects$() { - if (!this.$effects$?.length || !this.$container$) { - return; + if (this.$effects$) { + const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { + const effect = effectSubscriptions[EffectSubscriptionsProp.EFFECT]; + DEBUG && log(' schedule.effect', String(effect)); + if (isTask(effect)) { + effect.$flags$ |= TaskFlags.DIRTY; + assertDefined(this.$scheduler$, 'Scheduler must be defined.'); + this.$scheduler$(ChoreType.TASK, effectSubscriptions as fixMeAny); + } else if (effect instanceof ComputedSignal2) { + // we don't schedule ComputedSignal directly, instead we invalidate it and + // and schedule the signals effects (recursively) + effect.$invalid$ = true; + effect.$effects$?.forEach(scheduleEffect); + } else { + throw new Error('Not implemented'); + } + }; + this.$effects$.forEach(scheduleEffect); } - const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { - const effect = effectSubscriptions[0]; - DEBUG && log(' schedule.effect', String(effect)); - if (isTask(effect)) { - const scheduler = this.$container$!.$scheduler$; - assertDefined(scheduler, 'Scheduler must be defined.'); - scheduler(ChoreType.TASK, effectSubscriptions as fixMeAny); - } else if (effect instanceof ComputedSignal2) { - effect.$invalidate$(); - } else { - throw new Error('Not implemented'); - } - }; - this.$effects$.forEach(scheduleEffect); - this.$effects$.length = 0; + DEBUG && log('done scheduling'); } @@ -193,22 +198,17 @@ class Signal2 implements ISignal2 { } } toString() { - return `[Signal ${String(this.value)}]`; + return `[Signal ${String(this.$untrackedValue$)}]`; } toJSON() { - return { value: this.value }; + return { value: this.$untrackedValue$ }; } } -qDev && - (Signal2.prototype.toString = () => { - return 'Signal2'; - }); - /** Ensure the item is in array (do nothing if already there) */ function ensureContains(array: any[], value: any) { - const idx = array.indexOf(value); - if (idx === -1) { + const isMissing = array.indexOf(value) === -1; + if (isMissing) { array.push(value); } } @@ -230,11 +230,11 @@ class ComputedSignal2 extends Signal2 { // we need the old value to know if effects need running after computation $invalid$: boolean = true; - constructor(container: Container2 | null, computeTask: QRLInternal<() => T> | null) { + constructor(scheduler: Scheduler | null, computeTask: QRLInternal<() => T> | null) { assertDefined(computeTask, 'compute QRL must be provided'); // The value is used for comparison when signals trigger, which can only happen // when it was calculated before. Therefore we can pass whatever we like. - super(container, NEEDS_COMPUTATION); + super(scheduler, NEEDS_COMPUTATION); this.$computeQrl$ = computeTask; } @@ -262,6 +262,7 @@ class ComputedSignal2 extends Signal2 { get untrackedValue() { this.$computeIfNeeded$(); + assertFalse(this.$untrackedValue$ === NEEDS_COMPUTATION, 'Invalid state') return this.$untrackedValue$; } @@ -275,19 +276,28 @@ class ComputedSignal2 extends Signal2 { 'Computed signals must run sync. Expected the QRL to be resolved at this point.' ); - // TODO locale (ideally move to global state) - const ctx = newInvokeContext(); - ctx.$effectSubscriber$ = [this, null]; - ctx.$container2$ = this.$container$!; - const untrackedValue = computeQrl.getFn(ctx)() as T; - assertFalse(isPromise(untrackedValue), 'Computed function must be synchronous.'); - DEBUG && log('Signal.$compute$', untrackedValue); - this.$invalid$ = false; - - const didChange = untrackedValue !== this.$untrackedValue$; - this.$untrackedValue$ = untrackedValue; - - return didChange; + const ctx = tryGetInvokeContext(); + assertDefined(computeQrl, 'Signal is marked as dirty, but no compute function is provided.'); + const previousEffectSubscription = ctx?.$effectSubscriber$; + ctx && (ctx.$effectSubscriber$ = [this, null]); + assertTrue( + !!computeQrl.resolved, + 'Computed signals must run sync. Expected the QRL to be resolved at this point.' + ); + try { + const untrackedValue = computeQrl.getFn(ctx)() as T; + assertFalse(isPromise(untrackedValue), 'Computed function must be synchronous.'); + DEBUG && log('Signal.$compute$', untrackedValue); + this.$invalid$ = false; + + const didChange = untrackedValue !== this.$untrackedValue$; + this.$untrackedValue$ = untrackedValue; + return didChange; + } finally { + if (ctx) { + ctx.$effectSubscriber$ = previousEffectSubscription; + } + } } // Getters don't get inherited @@ -299,8 +309,3 @@ class ComputedSignal2 extends Signal2 { throw new TypeError('ComputedSignal is read-only'); } } - -qDev && - (ComputedSignal2.prototype.toString = () => { - return 'ComputedSignal2'; - }); diff --git a/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx b/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx index f10e03172f8..db13418b109 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx +++ b/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx @@ -1,19 +1,19 @@ +import { $, type ValueOrPromise } from '@builder.io/qwik'; import { createDocument, getTestPlatform } from '@builder.io/qwik/testing'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { inlinedQrl } from '../../qrl/qrl'; import { type QRLInternal } from '../../qrl/qrl-class'; import { type QRL } from '../../qrl/qrl.public'; import type { VirtualElement } from '../../render/dom/virtual-element'; -import { invoke, newInvokeContext, tryGetInvokeContext } from '../../use/use-core'; +import { invoke, newInvokeContext } from '../../use/use-core'; import { Task } from '../../use/use-task'; import { implicit$FirstArg } from '../../util/implicit_dollar'; +import { isPromise } from '../../util/promises'; import { getDomContainer } from '../client/dom-container'; import { ChoreType } from '../shared/scheduler'; import type { Container2 } from '../shared/types'; -import { createComputed2Qrl, createSignal2 } from './v2-signal.public'; -import { isPromise } from '../../util/promises'; -import { $, type ValueOrPromise } from '@builder.io/qwik'; import type { EffectSubscriptions } from './v2-signal'; +import { createComputed2Qrl, createSignal2, type ReadonlySignal2 } from './v2-signal.public'; describe('v2-signal', () => { const log: any[] = []; @@ -44,7 +44,7 @@ describe('v2-signal', () => { await withContainer(async () => { const signal = createSignal2(123); expect(signal.value).toEqual(123); - await effect$(() => log.push(signal.value)); + effect$(() => log.push(signal.value)); expect(log).toEqual([123]); signal.value++; expect(log).toEqual([123]); @@ -56,27 +56,36 @@ describe('v2-signal', () => { describe('computed', () => { it('should simulate lazy loaded QRLs', async () => { - const qrl = delay($(() => 'OK')); + const qrl = delayQrl($(() => 'OK')); expect(qrl.resolved).not.toBeDefined(); await qrl.resolve(); expect(qrl.resolved).toBeDefined(); }); - it.only('basic subscription operation', async () => { + it('basic subscription operation', async () => { await withContainer(async () => { const a = createSignal2(2); const b = createSignal2(10); await retry(() => { - const signal = createComputed2Qrl(delay($(() => a.value + b.value))); - expect((signal as any).$untrackedValue$).not.toEqual(12); - // This won't register a subscriber because there isn't any, - // but it will update the value and store the container. - expect(signal.value).toEqual(12); - expect((signal as any).$untrackedValue$).toEqual(12); - effect$(() => log.push(signal.value)); + let signal!: ReadonlySignal2; + effect$(() => { + signal = + signal || + createComputed2Qrl( + delayQrl( + $(() => { + return a.value + b.value; + }) + ) + ); + if (!log.length) { + expect(signal.untrackedValue).toEqual(12); + } + log.push(signal.value); // causes subscription + }); expect(log).toEqual([12]); - a.value++; - b.value += 10; + a.value = a.untrackedValue + 1; + b.value = b.untrackedValue + 10; // effects must run async expect(log).toEqual([12]); }); @@ -88,30 +97,30 @@ describe('v2-signal', () => { it('force', () => withContainer(async () => { const obj = { count: 0 }; - await retry(async () => { - const computed = createComputed2Qrl( - delay( + const computed = await retry(() => { + return createComputed2Qrl( + delayQrl( $(() => { obj.count++; return obj; }) ) ); - expect(computed.value).toBe(obj); - expect(obj.count).toBe(1); - effect$(() => log.push(computed.value.count)); - await flushSignals(); - expect(log).toEqual([1]); - expect(obj.count).toBe(1); - // mark dirty but value remains shallow same after calc - (computed as any).$invalid$ = true; - computed.value.count; - await flushSignals(); - expect(log).toEqual([1]); - expect(obj.count).toBe(2); - // force recalculation+notify - computed.force(); }); + expect(computed.value).toBe(obj); + expect(obj.count).toBe(1); + effect$(() => log.push(computed!.value.count)); + await flushSignals(); + expect(log).toEqual([1]); + expect(obj.count).toBe(1); + // mark dirty but value remains shallow same after calc + (computed as any).$invalid$ = true; + computed.value.count; + await flushSignals(); + expect(log).toEqual([1]); + expect(obj.count).toBe(2); + // force recalculation+notify + computed.force(); await flushSignals(); expect(log).toEqual([1, 3]); })); @@ -125,17 +134,16 @@ describe('v2-signal', () => { } function flushSignals() { - console.log('flushSignals()'); return container.$scheduler$(ChoreType.WAIT_FOR_ALL); } /** Simulates the QRLs being lazy loaded once per test. */ - function delay(qrl: QRL<() => T>): QRLInternal<() => T> { + function delayQrl(qrl: QRL<() => T>): QRLInternal<() => T> { const iQrl = qrl as QRLInternal<() => T>; const hash = iQrl.$symbol$; let delayQrl = delayMap.get(hash); if (!delayQrl) { - console.log('DELAY', hash); + // console.log('DELAY', hash); delayQrl = inlinedQrl( Promise.resolve(iQrl.resolve()), 'd_' + iQrl.$symbol$, @@ -145,36 +153,32 @@ describe('v2-signal', () => { } return delayQrl; } -}); -async function effectQrl(fnQrl: QRL<() => void>) { - const element: VirtualElement = null!; - const task = new Task(0, 0, element, fnQrl as QRLInternal, undefined, null); - const subscriber: EffectSubscriptions = [task, null]; - const fn = (fnQrl as QRLInternal<() => void>).$resolveLazy$(); - if (isPromise(fn)) { - throw fn; - } else { - const ctx = newInvokeContext(); - ctx.$effectSubscriber$ = subscriber; - try { - invoke(ctx, fn); - } catch (e) { - console.error('effect$ failed', fn.toString(), e); - throw e; + function effectQrl(fnQrl: QRL<() => void>) { + const qrl = fnQrl as QRLInternal<() => void>; + const element: VirtualElement = null!; + const task = new Task(0, 0, element, fnQrl as QRLInternal, undefined, null); + if (!qrl.resolved) { + throw qrl.resolve(); + } else { + const ctx = newInvokeContext(); + ctx.$container2$ = container; + const subscriber: EffectSubscriptions = [task, ctx]; + ctx.$effectSubscriber$ = subscriber; + return invoke(ctx, qrl.getFn(ctx)); } } -} -const effect$ = /*#__PURE__*/ implicit$FirstArg(effectQrl); + const effect$ = /*#__PURE__*/ implicit$FirstArg(effectQrl); -function retry(fn: () => T, ctx = tryGetInvokeContext()): ValueOrPromise { - try { - return invoke(ctx, fn); - } catch (e) { - if (isPromise(e)) { - return e.then(retry.bind(null, fn, ctx)) as ValueOrPromise; + function retry(fn: () => T): ValueOrPromise { + try { + return fn(); + } catch (e) { + if (isPromise(e)) { + return e.then(retry.bind(null, fn)) as ValueOrPromise; + } + throw e; } - throw e; } -} +}); From e832da5fbff544ecdd9729b4a7a4c576662b3a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Thu, 4 Jul 2024 20:36:34 +0200 Subject: [PATCH 12/89] WIP: basic --- packages/qwik/src/core/examples.tsx | 2 +- packages/qwik/src/core/render/dom/render.unit.tsx | 2 +- .../qwik/src/core/render/ssr/render-ssr.unit.tsx | 2 +- packages/qwik/src/core/use/use-core.ts | 15 ++++++++++++--- .../qwik/src/core/use/use-lexical-scope.public.ts | 2 +- packages/qwik/src/core/use/use-signal.ts | 7 +++---- packages/qwik/src/core/use/use-task.unit.ts | 2 +- packages/qwik/src/core/v2/signal/v2-signal.ts | 9 ++++++--- .../qwik/src/core/v2/tests/use-signal.spec.tsx | 4 ++-- .../qwik/src/core/v2/tests/use-store.spec.tsx | 2 +- packages/qwik/src/core/v2/tests/use-task.spec.tsx | 2 +- .../src/core/v2/tests/use-visible-task.spec.tsx | 2 +- 12 files changed, 31 insertions(+), 20 deletions(-) diff --git a/packages/qwik/src/core/examples.tsx b/packages/qwik/src/core/examples.tsx index 80f1fa67600..2dc4b5a2cc4 100644 --- a/packages/qwik/src/core/examples.tsx +++ b/packages/qwik/src/core/examples.tsx @@ -12,7 +12,7 @@ import { $, type QRL } from './qrl/qrl.public'; import { useOn, useOnDocument, useOnWindow } from './use/use-on'; import { useStore } from './use/use-store.public'; import { useStyles$, useStylesScoped$ } from './use/use-styles'; -import { useVisibleTask$, useTask$ } from './use/use-task'; +import { useVisibleTask$, useTask$ } from './use/use-task-dollar'; import { implicit$FirstArg } from './util/implicit_dollar'; import { isServer, isBrowser } from '../build'; diff --git a/packages/qwik/src/core/render/dom/render.unit.tsx b/packages/qwik/src/core/render/dom/render.unit.tsx index 484626c93f2..532650ad0c1 100644 --- a/packages/qwik/src/core/render/dom/render.unit.tsx +++ b/packages/qwik/src/core/render/dom/render.unit.tsx @@ -9,7 +9,7 @@ import { component$ } from '../../component/component.public'; import { inlinedQrl } from '../../qrl/qrl'; import { useLexicalScope } from '../../use/use-lexical-scope.public'; import { useStore } from '../../use/use-store.public'; -import { useVisibleTask$, useTask$ } from '../../use/use-task'; +import { useVisibleTask$, useTask$ } from '../../use/use-task-dollar'; import { useOn } from '../../use/use-on'; import { Slot } from '../jsx/slot.public'; import { render } from './render.public'; diff --git a/packages/qwik/src/core/render/ssr/render-ssr.unit.tsx b/packages/qwik/src/core/render/ssr/render-ssr.unit.tsx index 1d20fbeea1e..3b3183dcd60 100644 --- a/packages/qwik/src/core/render/ssr/render-ssr.unit.tsx +++ b/packages/qwik/src/core/render/ssr/render-ssr.unit.tsx @@ -8,7 +8,7 @@ import { createContextId, useContext, useContextProvider } from '../../use/use-c import { useOn, useOnDocument, useOnWindow } from '../../use/use-on'; import { Resource, useResource$ } from '../../use/use-resource'; import { useStylesScopedQrl, useStylesQrl } from '../../use/use-styles'; -import { useVisibleTask$, useTask$ } from '../../use/use-task'; +import { useVisibleTask$, useTask$ } from '../../use/use-task-dollar'; import { delay } from '../../util/promises'; import { SSRComment, SSRRaw } from '../jsx/utils.public'; import { Slot } from '../jsx/slot.public'; diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index 256f47135e7..c9593c42949 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -250,9 +250,18 @@ const trackInvocation = /*#__PURE__*/ newInvokeContext( * * @public */ -export const trackSignal = (signal: Signal, sub: Subscriber): T => { - trackInvocation.$subscriber$ = sub; - return invoke(trackInvocation, () => signal.value); +export const trackSignal = (signal: Signal, sub: Subscriber, container?: Container2): T => { + trackInvocation.$subscriber$ = sub; // todo(mhevery): delete me after signal 2 + const previousSubscriber = trackInvocation.$effectSubscriber$; + const previousContainer = trackInvocation.$container2$; + try { + trackInvocation.$effectSubscriber$ = [sub[1] as fixMeAny, trackInvocation]; + trackInvocation.$container2$ = container; + return invoke(trackInvocation, () => signal.value); + } finally { + trackInvocation.$effectSubscriber$ = previousSubscriber; + trackInvocation.$container2$ = previousContainer; + } }; export const trackRead = (readFn: () => T, sub: Subscriber): T => { diff --git a/packages/qwik/src/core/use/use-lexical-scope.public.ts b/packages/qwik/src/core/use/use-lexical-scope.public.ts index 8cf1d0494b2..52fa3593a9f 100644 --- a/packages/qwik/src/core/use/use-lexical-scope.public.ts +++ b/packages/qwik/src/core/use/use-lexical-scope.public.ts @@ -26,7 +26,7 @@ export const useLexicalScope = (): VARS => { let qrl = context.$qrl$ as QRLInternal | undefined; if (!qrl) { const el = context.$element$; - computeTask.$qrl$assertDefined( + assertDefined( el, 'invoke: element must be defined inside useLexicalScope()', context diff --git a/packages/qwik/src/core/use/use-signal.ts b/packages/qwik/src/core/use/use-signal.ts index 90d0467c6dd..d0ea5fc7fc3 100644 --- a/packages/qwik/src/core/use/use-signal.ts +++ b/packages/qwik/src/core/use/use-signal.ts @@ -1,6 +1,7 @@ import { isQwikComponent } from '../component/component.public'; import { _createSignal, type Signal } from '../state/signal'; import { isFunction } from '../util/types'; +import { createSignal2 } from '../v2/signal/v2-signal.public'; import { invoke } from './use-core'; import { useSequentialScope } from './use-sequential-scope'; @@ -12,17 +13,15 @@ export interface UseSignal { /** @public */ export const useSignal: UseSignal = (initialState?: STATE): Signal => { - const { val, set, iCtx } = useSequentialScope>(); + const { val, set } = useSequentialScope>(); if (val != null) { return val; } - const subsManager = - iCtx.$container2$?.$subsManager$ || iCtx.$renderCtx$.$static$.$containerState$.$subsManager$; const value = isFunction(initialState) && !isQwikComponent(initialState) ? invoke(undefined, initialState as any) : initialState; - const signal = _createSignal(value, subsManager, 0, undefined) as Signal; + const signal = createSignal2(value) return set(signal); }; diff --git a/packages/qwik/src/core/use/use-task.unit.ts b/packages/qwik/src/core/use/use-task.unit.ts index 6074c2c7370..0275132618f 100644 --- a/packages/qwik/src/core/use/use-task.unit.ts +++ b/packages/qwik/src/core/use/use-task.unit.ts @@ -3,7 +3,7 @@ import { component$ } from '../component/component.public'; import { useResource$ } from './use-resource'; import { useSignal } from './use-signal'; import { useStore } from './use-store.public'; -import { useTask$ } from './use-task'; +import { useTask$ } from './use-task-dollar'; describe('types', () => { test('track', () => () => { diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index 303e6643fbe..8f8c0709372 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -16,6 +16,7 @@ import { qwikDebugToString } from '../../debug'; import { assertDefined, assertFalse, assertTrue } from '../../error/assert'; import { type QRLInternal } from '../../qrl/qrl-class'; import type { QRL } from '../../qrl/qrl.public'; +import type { JSXOutput } from '../../render/jsx/types/jsx-node'; import { tryGetInvokeContext, type InvokeContext @@ -25,7 +26,7 @@ import { isPromise } from '../../util/promises'; import { qDev } from '../../util/qdev'; import type { VNode } from '../client/types'; import { ChoreType, type Scheduler } from '../shared/scheduler'; -import type { fixMeAny } from '../shared/types'; +import type { HostElement, fixMeAny } from '../shared/types'; import type { Signal2 as ISignal2 } from './v2-signal.public'; const DEBUG = true; @@ -172,9 +173,9 @@ class Signal2 implements ISignal2 { const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { const effect = effectSubscriptions[EffectSubscriptionsProp.EFFECT]; DEBUG && log(' schedule.effect', String(effect)); + assertDefined(this.$scheduler$, 'Scheduler must be defined.'); if (isTask(effect)) { effect.$flags$ |= TaskFlags.DIRTY; - assertDefined(this.$scheduler$, 'Scheduler must be defined.'); this.$scheduler$(ChoreType.TASK, effectSubscriptions as fixMeAny); } else if (effect instanceof ComputedSignal2) { // we don't schedule ComputedSignal directly, instead we invalidate it and @@ -182,7 +183,9 @@ class Signal2 implements ISignal2 { effect.$invalid$ = true; effect.$effects$?.forEach(scheduleEffect); } else { - throw new Error('Not implemented'); + const host: HostElement = effect as any; + const target = host; + this.$scheduler$(ChoreType.NODE_DIFF, host, target, this.$untrackedValue$ as JSXOutput); } }; this.$effects$.forEach(scheduleEffect); diff --git a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx index e917f4a90d4..93a4e5e38f6 100644 --- a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx @@ -15,14 +15,14 @@ import type { Signal as SignalType } from '../../state/signal'; import { untrack } from '../../use/use-core'; import { useSignal } from '../../use/use-signal'; -const debug = false; //true; +const debug = true; //true; Error.stackTraceLimit = 100; describe.each([ { render: ssrRenderToDom }, // { render: domRender }, // ])('$render.name: useSignal', ({ render }) => { - it('should update value', async () => { + it.only('should update value', async () => { const Counter = component$((props: { initial: number }) => { const count = useSignal(props.initial); return ; diff --git a/packages/qwik/src/core/v2/tests/use-store.spec.tsx b/packages/qwik/src/core/v2/tests/use-store.spec.tsx index 403d2a66349..132e157857d 100644 --- a/packages/qwik/src/core/v2/tests/use-store.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-store.spec.tsx @@ -8,7 +8,7 @@ import type { Signal as SignalType } from '../../state/signal'; import { untrack } from '../../use/use-core'; import { useSignal } from '../../use/use-signal'; import { useStore } from '../../use/use-store.public'; -import { useTask$ } from '../../use/use-task'; +import { useTask$ } from '../../use/use-task-dollar'; const debug = false; //true; Error.stackTraceLimit = 100; diff --git a/packages/qwik/src/core/v2/tests/use-task.spec.tsx b/packages/qwik/src/core/v2/tests/use-task.spec.tsx index 56d4562f2c8..fd8265b37d6 100644 --- a/packages/qwik/src/core/v2/tests/use-task.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-task.spec.tsx @@ -8,7 +8,7 @@ import { Fragment as Component, Fragment, Fragment as Signal } from '../../rende import { SignalDerived, type Signal as SignalType } from '../../state/signal'; import { useSignal } from '../../use/use-signal'; import { useStore } from '../../use/use-store.public'; -import { useTask$ } from '../../use/use-task'; +import { useTask$ } from '../../use/use-task-dollar'; import { delay } from '../../util/promises'; const debug = false; //true; diff --git a/packages/qwik/src/core/v2/tests/use-visible-task.spec.tsx b/packages/qwik/src/core/v2/tests/use-visible-task.spec.tsx index ccfab68fb9a..d3c50be7afa 100644 --- a/packages/qwik/src/core/v2/tests/use-visible-task.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-visible-task.spec.tsx @@ -6,7 +6,7 @@ import { component$ } from '../../component/component.public'; import { Fragment as Component, Fragment, Fragment as Signal } from '../../render/jsx/jsx-runtime'; import { useSignal } from '../../use/use-signal'; import { useStore } from '../../use/use-store.public'; -import { useVisibleTask$ } from '../../use/use-task'; +import { useVisibleTask$ } from '../../use/use-task-dollar'; import { delay } from '../../util/promises'; const debug = false; //true; From ec285a252437fb0187b4aa4c74caeee66bc1334f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Fri, 5 Jul 2024 22:40:17 +0200 Subject: [PATCH 13/89] WIP: progress --- packages/qwik/src/core/debug.ts | 14 ++- packages/qwik/src/core/qrl/inlined-fn.ts | 3 +- .../qwik/src/core/render/jsx/jsx-runtime.ts | 3 + packages/qwik/src/core/use/use-core.ts | 13 +- packages/qwik/src/core/use/use-signal.ts | 2 +- .../qwik/src/core/v2/client/vnode-diff.ts | 12 +- packages/qwik/src/core/v2/shared/scheduler.ts | 2 +- packages/qwik/src/core/v2/signal/v2-signal.ts | 115 ++++++++++++++---- .../qwik/src/core/v2/ssr/ssr-render-jsx.ts | 2 +- .../src/core/v2/tests/use-signal.spec.tsx | 7 +- 10 files changed, 128 insertions(+), 45 deletions(-) diff --git a/packages/qwik/src/core/debug.ts b/packages/qwik/src/core/debug.ts index e6e8b9677bb..f74bb565a11 100644 --- a/packages/qwik/src/core/debug.ts +++ b/packages/qwik/src/core/debug.ts @@ -1,16 +1,22 @@ import { isQrl } from "../server/prefetch-strategy"; import { isTask } from "./use/use-task"; -import { isSignal2 } from "./v2/signal/v2-signal"; +import { vnode_isVNode, vnode_toString } from "./v2/client/vnode"; export function qwikDebugToString(obj: any): any { if (Array.isArray(obj)) { - return obj.map(qwikDebugToString); + if (vnode_isVNode(obj)) { + return vnode_toString.apply(obj); + } else { + return obj.map(qwikDebugToString); + } } else if (isTask(obj)) { return `Task(${qwikDebugToString(obj.$qrl$)})` } else if (isQrl(obj)) { return `Qrl(${obj.$symbol$})` - } else if (isSignal2(obj)) { - return String(obj) } return obj; +} + +export const pad = (text: string, prefix: string) => { + return String(text).split('\n').map((line, idx) => (idx ? prefix : '') + line).join('\n'); } \ No newline at end of file diff --git a/packages/qwik/src/core/qrl/inlined-fn.ts b/packages/qwik/src/core/qrl/inlined-fn.ts index 9a742bca716..2d1a82fe92f 100644 --- a/packages/qwik/src/core/qrl/inlined-fn.ts +++ b/packages/qwik/src/core/qrl/inlined-fn.ts @@ -1,6 +1,7 @@ import { assertDefined } from '../error/assert'; import { SignalDerived } from '../state/signal'; import { qSerialize } from '../util/qdev'; +import { DerivedSignal } from '../v2/signal/v2-signal'; /** @internal */ export const _fnSignal = any>( @@ -8,7 +9,7 @@ export const _fnSignal = any>( args: Parameters, fnStr?: string ) => { - return new SignalDerived, Parameters>(fn, args, fnStr); + return new DerivedSignal(null, fn, args, fnStr || null); }; export const serializeDerivedSignalFunc = (signal: SignalDerived) => { diff --git a/packages/qwik/src/core/render/jsx/jsx-runtime.ts b/packages/qwik/src/core/render/jsx/jsx-runtime.ts index 13b4a3310b6..ec67e96e4e8 100644 --- a/packages/qwik/src/core/render/jsx/jsx-runtime.ts +++ b/packages/qwik/src/core/render/jsx/jsx-runtime.ts @@ -18,6 +18,7 @@ import type { DevJSX, FunctionComponent, JSXNode } from './types/jsx-node'; import type { QwikJSX } from './types/jsx-qwik'; import type { JSXChildren } from './types/jsx-qwik-attributes'; import { SkipRender } from './utils.public'; +import { isSignal2 } from '../../v2/signal/v2-signal'; export type Props = Record; @@ -376,6 +377,8 @@ export const isValidJSXChild = (node: unknown): node is JsxChild => { } if (isSignal(node)) { return isValidJSXChild(node.value); + } else if (isSignal2(node)) { + return isValidJSXChild(node.untrackedValue); } else if (isPromise(node)) { return true; } diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index c9593c42949..c325383e384 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -30,7 +30,7 @@ import { } from '../v2/client/vnode'; import { _getQContainerElement } from '../v2/client/dom-container'; import type { ContainerElement } from '../v2/client/types'; -import type { EffectSubscriptions } from '../v2/signal/v2-signal'; +import type { Effect, EffectSubscriptions } from '../v2/signal/v2-signal'; declare const document: QwikDocument; @@ -250,20 +250,25 @@ const trackInvocation = /*#__PURE__*/ newInvokeContext( * * @public */ -export const trackSignal = (signal: Signal, sub: Subscriber, container?: Container2): T => { +export const trackSignal = (signal: Signal, sub: Subscriber): T => { trackInvocation.$subscriber$ = sub; // todo(mhevery): delete me after signal 2 + return invoke(trackInvocation, () => signal.value); +}; + +export const trackSignal2 = (fn: () => T, sub: Effect, container: Container2): T => { const previousSubscriber = trackInvocation.$effectSubscriber$; const previousContainer = trackInvocation.$container2$; try { - trackInvocation.$effectSubscriber$ = [sub[1] as fixMeAny, trackInvocation]; + trackInvocation.$effectSubscriber$ = [sub, null]; trackInvocation.$container2$ = container; - return invoke(trackInvocation, () => signal.value); + return invoke(trackInvocation, fn); } finally { trackInvocation.$effectSubscriber$ = previousSubscriber; trackInvocation.$container2$ = previousContainer; } }; + export const trackRead = (readFn: () => T, sub: Subscriber): T => { trackInvocation.$subscriber$ = sub; return invoke(trackInvocation, readFn); diff --git a/packages/qwik/src/core/use/use-signal.ts b/packages/qwik/src/core/use/use-signal.ts index d0ea5fc7fc3..96735be43d5 100644 --- a/packages/qwik/src/core/use/use-signal.ts +++ b/packages/qwik/src/core/use/use-signal.ts @@ -22,6 +22,6 @@ export const useSignal: UseSignal = (initialState?: STATE): Signal isFunction(initialState) && !isQwikComponent(initialState) ? invoke(undefined, initialState as any) : initialState; - const signal = createSignal2(value) + const signal = createSignal2(value); return set(signal); }; diff --git a/packages/qwik/src/core/v2/client/vnode-diff.ts b/packages/qwik/src/core/v2/client/vnode-diff.ts index 439f74e8522..a6b1dda1e26 100644 --- a/packages/qwik/src/core/v2/client/vnode-diff.ts +++ b/packages/qwik/src/core/v2/client/vnode-diff.ts @@ -12,7 +12,7 @@ import type { JSXChildren } from '../../render/jsx/types/jsx-qwik-attributes'; import { SSRComment, SSRRaw, SkipRender } from '../../render/jsx/utils.public'; import { SubscriptionType } from '../../state/common'; import { SignalDerived, isSignal } from '../../state/signal'; -import { trackSignal } from '../../use/use-core'; +import { trackSignal, trackSignal2 } from '../../use/use-core'; import { TaskFlags, cleanupTask, isTask, type SubscriberEffect } from '../../use/use-task'; import { EMPTY_OBJ } from '../../util/flyweight'; import { @@ -88,6 +88,7 @@ import { type VNodeJournal, } from './vnode'; import { getNewElementNamespaceData } from './vnode-namespace'; +import { isSignal2 } from '../signal/v2-signal'; export type ComponentQueue = Array; @@ -176,15 +177,12 @@ export const vnode_diff = ( } else if (jsxValue && typeof jsxValue === 'object') { if (Array.isArray(jsxValue)) { descend(jsxValue, false); - } else if (isSignal(jsxValue)) { + } else if (isSignal2(jsxValue)) { expectVirtual(VirtualType.DerivedSignal, null); descend( - trackSignal(jsxValue, [ - SubscriptionType.TEXT_MUTABLE, + trackSignal2(() => jsxValue.value, vCurrent || (vNewNode as fixMeAny), // This should be host, but not sure why - jsxValue, - vCurrent || (vNewNode as fixMeAny), - ]), + container), true ); } else if (isPromise(jsxValue)) { diff --git a/packages/qwik/src/core/v2/shared/scheduler.ts b/packages/qwik/src/core/v2/shared/scheduler.ts index 9772896b024..66083bde0cc 100644 --- a/packages/qwik/src/core/v2/shared/scheduler.ts +++ b/packages/qwik/src/core/v2/shared/scheduler.ts @@ -102,7 +102,7 @@ import type { Container2, HostElement, fixMeAny } from './types'; import { EffectSubscriptionsProp, type EffectSubscriptions } from '../signal/v2-signal'; // Turn this on to get debug output of what the scheduler is doing. -const DEBUG: boolean = true; +const DEBUG: boolean = false; export const enum ChoreType { /// MASKS defining three levels of sorting diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index 8f8c0709372..bba0ef9e106 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -12,12 +12,13 @@ * - It is `Readonly` because it is computed. */ -import { qwikDebugToString } from '../../debug'; +import { pad, qwikDebugToString } from '../../debug'; import { assertDefined, assertFalse, assertTrue } from '../../error/assert'; import { type QRLInternal } from '../../qrl/qrl-class'; import type { QRL } from '../../qrl/qrl.public'; import type { JSXOutput } from '../../render/jsx/types/jsx-node'; import { + trackSignal2, tryGetInvokeContext, type InvokeContext } from '../../use/use-core'; @@ -25,8 +26,8 @@ import { Task, TaskFlags, isTask } from '../../use/use-task'; import { isPromise } from '../../util/promises'; import { qDev } from '../../util/qdev'; import type { VNode } from '../client/types'; -import { ChoreType, type Scheduler } from '../shared/scheduler'; -import type { HostElement, fixMeAny } from '../shared/types'; +import { ChoreType } from '../shared/scheduler'; +import type { Container2, HostElement, fixMeAny } from '../shared/types'; import type { Signal2 as ISignal2 } from './v2-signal.public'; const DEBUG = true; @@ -72,7 +73,7 @@ export const isSignal2 = (value: any): value is ISignal2 => { * - `VNode`: Either a component or `` * - `Signal2`: A derived signal which contains a computation function. */ -type Effect = Task | VNode | Signal2; +export type Effect = Task | VNode | Signal2; /** * An effect plus a list of subscriptions effect depends on. @@ -119,10 +120,10 @@ class Signal2 implements ISignal2 { // TODO perf: use a set? protected $effects$: null | EffectSubscriptions[] = null; - protected $scheduler$: Scheduler | null = null; + protected $container$: Container2 | null = null; - constructor(scheduler: Scheduler | null, value: T) { - this.$scheduler$ = scheduler; + constructor(container: Container2 | null, value: T) { + this.$container$ = container; this.$untrackedValue$ = value; } @@ -133,13 +134,13 @@ class Signal2 implements ISignal2 { get value() { const ctx = tryGetInvokeContext(); if (ctx) { - if (this.$scheduler$ === null) { - assertTrue(!!ctx.$container2$, 'container should be in context '); + if (this.$container$ === null) { + assertDefined(ctx.$container2$, 'container should be in context '); // Grab the container now we have access to it - this.$scheduler$ = ctx.$container2$!.$scheduler$; + this.$container$ = ctx.$container2$; } else { assertTrue( - !ctx.$container2$?.$scheduler$ || ctx.$container2$?.$scheduler$ === this.$scheduler$, + !ctx.$container2$ || ctx.$container2$ === this.$container$, 'Do not use signals across containers' ); } @@ -154,7 +155,7 @@ class Signal2 implements ISignal2 { // to unsubscribe from. So we need to store the reference from the effect back // to this signal. ensureContains(effectSubscriber, this); - DEBUG && log("read->sub", this.$untrackedValue$, effectSubscriber[EffectSubscriptionsProp.EFFECT]) + DEBUG && log("read->sub", pad('\n' + this.toString(), " ")) } } return this.untrackedValue; @@ -162,7 +163,7 @@ class Signal2 implements ISignal2 { set value(value) { if (value !== this.$untrackedValue$) { - DEBUG && log('Signal.set', this.$untrackedValue$, '->', value); + DEBUG && log('Signal.set', this.$untrackedValue$, '->', value, pad('\n' + this.toString(), " ")); this.$untrackedValue$ = value; this.$triggerEffects$(); } @@ -172,20 +173,21 @@ class Signal2 implements ISignal2 { if (this.$effects$) { const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { const effect = effectSubscriptions[EffectSubscriptionsProp.EFFECT]; - DEBUG && log(' schedule.effect', String(effect)); - assertDefined(this.$scheduler$, 'Scheduler must be defined.'); + assertDefined(this.$container$, 'Scheduler must be defined.'); if (isTask(effect)) { effect.$flags$ |= TaskFlags.DIRTY; - this.$scheduler$(ChoreType.TASK, effectSubscriptions as fixMeAny); - } else if (effect instanceof ComputedSignal2) { - // we don't schedule ComputedSignal directly, instead we invalidate it and + DEBUG && log('schedule.effect.task', pad('\n' + String(effect), ' ')); + this.$container$.$scheduler$(ChoreType.TASK, effectSubscriptions as fixMeAny); + } else if (effect instanceof Signal2) { + // we don't schedule ComputedSignal/DerivedSignal directly, instead we invalidate it and // and schedule the signals effects (recursively) - effect.$invalid$ = true; + (effect as ComputedSignal2 | DerivedSignal).$invalid$ = true; effect.$effects$?.forEach(scheduleEffect); } else { const host: HostElement = effect as any; const target = host; - this.$scheduler$(ChoreType.NODE_DIFF, host, target, this.$untrackedValue$ as JSXOutput); + DEBUG && log('schedule.effect.node_diff', pad('\n' + String(effect), ' ')); + this.$container$.$scheduler$(ChoreType.NODE_DIFF, host, target, this.$untrackedValue$ as JSXOutput); } }; this.$effects$.forEach(scheduleEffect); @@ -201,7 +203,8 @@ class Signal2 implements ISignal2 { } } toString() { - return `[Signal ${String(this.$untrackedValue$)}]`; + return `[${this.constructor.name}${(this as any).$invalid$ ? " INVALID" : ''} ${String(this.$untrackedValue$)}]` + + this.$effects$?.map(e => '\n -> ' + pad(qwikDebugToString(e[0]), ' ')).join('\n') || ''; } toJSON() { return { value: this.$untrackedValue$ }; @@ -216,6 +219,7 @@ function ensureContains(array: any[], value: any) { } } + /** * A signal which is computed from other signals. * @@ -233,11 +237,11 @@ class ComputedSignal2 extends Signal2 { // we need the old value to know if effects need running after computation $invalid$: boolean = true; - constructor(scheduler: Scheduler | null, computeTask: QRLInternal<() => T> | null) { + constructor(container: Container2 | null, computeTask: QRLInternal<() => T> | null) { assertDefined(computeTask, 'compute QRL must be provided'); // The value is used for comparison when signals trigger, which can only happen // when it was calculated before. Therefore we can pass whatever we like. - super(scheduler, NEEDS_COMPUTATION); + super(container, NEEDS_COMPUTATION); this.$computeQrl$ = computeTask; } @@ -312,3 +316,68 @@ class ComputedSignal2 extends Signal2 { throw new TypeError('ComputedSignal is read-only'); } } + +export class DerivedSignal extends Signal2 { + $args$: any[]; + $fn$: (...args: any[]) => T; + $fnStr$: string | null; + + // We need a separate flag to know when the computation needs running because + // we need the old value to know if effects need running after computation + $invalid$: boolean = true; + + constructor(container: Container2 | null, fn: (...args: any[]) => T, args: any[], fnStr: string | null) { + super(container, NEEDS_COMPUTATION); + this.$args$ = args; + this.$fn$ = fn; + this.$fnStr$ = fnStr; + } + + $invalidate$() { + this.$invalid$ = true; + if (!this.$effects$?.length) { + return; + } + // We should only call subscribers if the calculation actually changed. + // Therefore, we need to calculate the value now. + // TODO move this calculation to the beginning of the next tick, add chores to that tick if necessary. New chore type? + if (this.$computeIfNeeded$()) { + this.$triggerEffects$(); + } + } + + /** + * Use this to force running subscribers, for example when the calculated value has mutated but + * remained the same object + */ + force() { + this.$invalid$ = true; + this.$triggerEffects$(); + } + + get untrackedValue() { + if (this.$invalid$ && !this.$container$) { + // This is a hack to handle isValidJSXChild. Unsure why this is needed. + return this.$fn$(...this.$args$); + } + this.$computeIfNeeded$(); + assertFalse(this.$untrackedValue$ === NEEDS_COMPUTATION, 'Invalid state') + return this.$untrackedValue$; + } + + private $computeIfNeeded$() { + if (!this.$invalid$) { + return false; + } + this.$untrackedValue$ = trackSignal2(() => this.$fn$(...this.$args$), this, this.$container$!); + } + + // Getters don't get inherited + get value() { + return super.value; + } + + set value(_: any) { + throw new TypeError('DerivedSignal is read-only'); + } +} diff --git a/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts index 751f0236185..a7bff40ee16 100644 --- a/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts +++ b/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts @@ -146,7 +146,7 @@ function processJSXNode( // const host = ssr.getComponentFrame(0)!.componentNode as fixMeAny; const host = signalNode; enqueue(ssr.closeFragment); - enqueue(trackSignal(value, [SubscriptionType.TEXT_MUTABLE, host, value, signalNode])); + enqueue(trackSignal(value, [SubscriptionType.TEXT_MUTABLE, host, value, signalNode], ssr)); } else if (isPromise(value)) { ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.Awaited] : EMPTY_ARRAY); enqueue(ssr.closeFragment); diff --git a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx index 93a4e5e38f6..8732ab91d90 100644 --- a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx @@ -19,10 +19,10 @@ const debug = true; //true; Error.stackTraceLimit = 100; describe.each([ - { render: ssrRenderToDom }, // + // { render: ssrRenderToDom }, // { render: domRender }, // ])('$render.name: useSignal', ({ render }) => { - it.only('should update value', async () => { + it('should update value', async () => { const Counter = component$((props: { initial: number }) => { const count = useSignal(props.initial); return ; @@ -87,7 +87,7 @@ describe.each([ ); }); - it('should update from JSX', async () => { + it.only('should update from JSX', async () => { const Child = component$(() => { return ( @@ -114,6 +114,7 @@ describe.each([ ); + console.log('>>>>>>>>> CLICK'); await trigger(container.element, 'button', 'click'); expect(vNode).toMatchVDOM( <> From 1f7e06ae88b0fdfab503d64e5683ba37283c42b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Sat, 6 Jul 2024 17:21:31 +0200 Subject: [PATCH 14/89] WIP: another passing test --- packages/qwik/src/core/v2/shared/scheduler.ts | 7 +++++-- packages/qwik/src/core/v2/signal/v2-signal.ts | 15 +++++++++++++-- .../qwik/src/core/v2/tests/use-signal.spec.tsx | 1 - 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/qwik/src/core/v2/shared/scheduler.ts b/packages/qwik/src/core/v2/shared/scheduler.ts index 66083bde0cc..866c129a9ea 100644 --- a/packages/qwik/src/core/v2/shared/scheduler.ts +++ b/packages/qwik/src/core/v2/shared/scheduler.ts @@ -99,7 +99,7 @@ import { vnode_documentPosition, vnode_isVNode } from '../client/vnode'; import { vnode_diff } from '../client/vnode-diff'; import { executeComponent2 } from './component-execution'; import type { Container2, HostElement, fixMeAny } from './types'; -import { EffectSubscriptionsProp, type EffectSubscriptions } from '../signal/v2-signal'; +import { EffectSubscriptionsProp, isSignal2, type EffectSubscriptions } from '../signal/v2-signal'; // Turn this on to get debug output of what the scheduler is doing. const DEBUG: boolean = false; @@ -315,7 +315,10 @@ export const createScheduler = ( break; case ChoreType.NODE_DIFF: { const parentVirtualNode = chore.$target$ as VirtualVNode; - const jsx = chore.$payload$ as JSXOutput; + let jsx = chore.$payload$ as JSXOutput; + if (isSignal2(jsx)) { + jsx = jsx.value as any; + } returnValue = vnode_diff(container as fixMeAny, jsx, parentVirtualNode, null); break; } diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index bba0ef9e106..a1c45d1c301 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -171,6 +171,7 @@ class Signal2 implements ISignal2 { protected $triggerEffects$() { if (this.$effects$) { + let signal: Signal2 = this; const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { const effect = effectSubscriptions[EffectSubscriptionsProp.EFFECT]; assertDefined(this.$container$, 'Scheduler must be defined.'); @@ -181,13 +182,23 @@ class Signal2 implements ISignal2 { } else if (effect instanceof Signal2) { // we don't schedule ComputedSignal/DerivedSignal directly, instead we invalidate it and // and schedule the signals effects (recursively) + if (effect instanceof ComputedSignal2) { + // TODO(misko): ensure that the computed signal's QRL is resolved. + // If not resolved scheduled it to be resolved. + } (effect as ComputedSignal2 | DerivedSignal).$invalid$ = true; - effect.$effects$?.forEach(scheduleEffect); + const previousSignal = signal; + try { + signal = effect; + effect.$effects$?.forEach(scheduleEffect); + } finally { + signal = previousSignal; + } } else { const host: HostElement = effect as any; const target = host; DEBUG && log('schedule.effect.node_diff', pad('\n' + String(effect), ' ')); - this.$container$.$scheduler$(ChoreType.NODE_DIFF, host, target, this.$untrackedValue$ as JSXOutput); + this.$container$.$scheduler$(ChoreType.NODE_DIFF, host, target, signal as fixMeAny); } }; this.$effects$.forEach(scheduleEffect); diff --git a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx index 8732ab91d90..16740807b2f 100644 --- a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx @@ -114,7 +114,6 @@ describe.each([ ); - console.log('>>>>>>>>> CLICK'); await trigger(container.element, 'button', 'click'); expect(vNode).toMatchVDOM( <> From 2bd7afef2905a07c3e4b3b1b2b36901f82b402e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Tue, 9 Jul 2024 11:15:44 +0200 Subject: [PATCH 15/89] WIP: use-signal.spec.tsx passing in CSR --- packages/qwik/src/core/debug.ts | 46 +++++++++++---- packages/qwik/src/core/qrl/inlined-fn.ts | 4 +- packages/qwik/src/core/state/signal.ts | 5 ++ packages/qwik/src/core/use/use-core.ts | 5 +- .../qwik/src/core/v2/client/vnode-diff.ts | 29 ++++------ packages/qwik/src/core/v2/client/vnode.ts | 43 ++------------ packages/qwik/src/core/v2/shared/scheduler.ts | 57 ++++++++++++------- packages/qwik/src/core/v2/signal/v2-signal.ts | 49 +++++++++++----- .../src/core/v2/tests/use-signal.spec.tsx | 6 +- 9 files changed, 140 insertions(+), 104 deletions(-) diff --git a/packages/qwik/src/core/debug.ts b/packages/qwik/src/core/debug.ts index f74bb565a11..b3339f8a249 100644 --- a/packages/qwik/src/core/debug.ts +++ b/packages/qwik/src/core/debug.ts @@ -2,21 +2,45 @@ import { isQrl } from "../server/prefetch-strategy"; import { isTask } from "./use/use-task"; import { vnode_isVNode, vnode_toString } from "./v2/client/vnode"; -export function qwikDebugToString(obj: any): any { - if (Array.isArray(obj)) { - if (vnode_isVNode(obj)) { - return vnode_toString.apply(obj); - } else { - return obj.map(qwikDebugToString); +const stringifyPath: any[] = []; +export function qwikDebugToString(value: any): any { + if (value === null) { + return 'null'; + } else if (value === undefined) { + return 'undefined'; + } else if (typeof value === 'string') { + return '"' + value + '"'; + } else if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } else if (typeof value === 'object' || typeof value === 'function') { + if (stringifyPath.includes(value)) { + return '*'; + } + if (stringifyPath.length > 10) { + debugger; + } + try { + stringifyPath.push(value); + if (Array.isArray(value)) { + if (vnode_isVNode(value)) { + return vnode_toString.apply(value); + } else { + return value.map(qwikDebugToString); + } + } else if (isTask(value)) { + return `Task(${qwikDebugToString(value.$qrl$)})` + } else if (isQrl(value)) { + return `Qrl(${value.$symbol$})` + } + } finally { + stringifyPath.pop(); } - } else if (isTask(obj)) { - return `Task(${qwikDebugToString(obj.$qrl$)})` - } else if (isQrl(obj)) { - return `Qrl(${obj.$symbol$})` } - return obj; + return value; } + + export const pad = (text: string, prefix: string) => { return String(text).split('\n').map((line, idx) => (idx ? prefix : '') + line).join('\n'); } \ No newline at end of file diff --git a/packages/qwik/src/core/qrl/inlined-fn.ts b/packages/qwik/src/core/qrl/inlined-fn.ts index 2d1a82fe92f..d5e51aef67a 100644 --- a/packages/qwik/src/core/qrl/inlined-fn.ts +++ b/packages/qwik/src/core/qrl/inlined-fn.ts @@ -1,7 +1,7 @@ import { assertDefined } from '../error/assert'; import { SignalDerived } from '../state/signal'; import { qSerialize } from '../util/qdev'; -import { DerivedSignal } from '../v2/signal/v2-signal'; +import { DerivedSignal2 } from '../v2/signal/v2-signal'; /** @internal */ export const _fnSignal = any>( @@ -9,7 +9,7 @@ export const _fnSignal = any>( args: Parameters, fnStr?: string ) => { - return new DerivedSignal(null, fn, args, fnStr || null); + return new DerivedSignal2(null, fn, args, fnStr || null); }; export const serializeDerivedSignalFunc = (signal: SignalDerived) => { diff --git a/packages/qwik/src/core/state/signal.ts b/packages/qwik/src/core/state/signal.ts index 914bee5dfc6..c1bb67deb48 100644 --- a/packages/qwik/src/core/state/signal.ts +++ b/packages/qwik/src/core/state/signal.ts @@ -4,6 +4,7 @@ import { logWarn } from '../util/log'; import { ComputedEvent, RenderEvent, ResourceEvent } from '../util/markers'; import { qDev, qSerialize } from '../util/qdev'; import { isObject } from '../util/types'; +import { DerivedSignal2, isSignal2 } from '../v2/signal/v2-signal'; import { LocalSubscriptionManager, getProxyTarget, @@ -211,6 +212,10 @@ export const _wrapProp = , P extends keyof T>(obj: T, assertEqual(prop, 'value', 'Left side is a signal, prop must be value'); return new SignalDerived(getProp, [obj, prop as string]); } + if (isSignal2(obj)) { + assertEqual(prop, 'value', 'Left side is a signal, prop must be value'); + return new DerivedSignal2(null, getProp, [obj, prop as string], null); + } if (_CONST_PROPS in obj) { const constProps = (obj as any)[_CONST_PROPS]; if (constProps && prop in constProps) { diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index c325383e384..412b9b52408 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -255,11 +255,12 @@ export const trackSignal = (signal: Signal, sub: Subscriber): T => { return invoke(trackInvocation, () => signal.value); }; -export const trackSignal2 = (fn: () => T, sub: Effect, container: Container2): T => { +export const trackSignal2 = (fn: () => T, sub: Effect, property: string | boolean, container: Container2): T => { + console.log(">>>>>>>>>> PROPERTY", property); const previousSubscriber = trackInvocation.$effectSubscriber$; const previousContainer = trackInvocation.$container2$; try { - trackInvocation.$effectSubscriber$ = [sub, null]; + trackInvocation.$effectSubscriber$ = [sub, property]; trackInvocation.$container2$ = container; return invoke(trackInvocation, fn); } finally { diff --git a/packages/qwik/src/core/v2/client/vnode-diff.ts b/packages/qwik/src/core/v2/client/vnode-diff.ts index a6b1dda1e26..78795adbd50 100644 --- a/packages/qwik/src/core/v2/client/vnode-diff.ts +++ b/packages/qwik/src/core/v2/client/vnode-diff.ts @@ -10,9 +10,7 @@ import { Slot } from '../../render/jsx/slot.public'; import type { JSXNode, JSXOutput } from '../../render/jsx/types/jsx-node'; import type { JSXChildren } from '../../render/jsx/types/jsx-qwik-attributes'; import { SSRComment, SSRRaw, SkipRender } from '../../render/jsx/utils.public'; -import { SubscriptionType } from '../../state/common'; -import { SignalDerived, isSignal } from '../../state/signal'; -import { trackSignal, trackSignal2 } from '../../use/use-core'; +import { trackSignal2 } from '../../use/use-core'; import { TaskFlags, cleanupTask, isTask, type SubscriberEffect } from '../../use/use-task'; import { EMPTY_OBJ } from '../../util/flyweight'; import { @@ -88,7 +86,8 @@ import { type VNodeJournal, } from './vnode'; import { getNewElementNamespaceData } from './vnode-namespace'; -import { isSignal2 } from '../signal/v2-signal'; +import { DerivedSignal2, isSignal2 } from '../signal/v2-signal'; +import type { Signal2 } from '../signal/v2-signal.public'; export type ComponentQueue = Array; @@ -180,9 +179,12 @@ export const vnode_diff = ( } else if (isSignal2(jsxValue)) { expectVirtual(VirtualType.DerivedSignal, null); descend( - trackSignal2(() => jsxValue.value, + trackSignal2( + () => jsxValue.value, vCurrent || (vNewNode as fixMeAny), // This should be host, but not sure why - container), + false, + container + ), true ); } else if (isPromise(jsxValue)) { @@ -486,8 +488,8 @@ export const vnode_diff = ( const constProps = jsxValue.constProps; if (constProps && typeof constProps == 'object' && 'name' in constProps) { const constValue = constProps.name; - if (constValue instanceof SignalDerived) { - return trackSignal(constValue, [SubscriptionType.HOST, vHost as fixMeAny]); + if (constValue instanceof DerivedSignal2) { + return trackSignal2(() => constValue.value, vHost as fixMeAny, true, container); } } return jsxValue.props.name || QDefaultSlot; @@ -572,19 +574,12 @@ export const vnode_diff = ( continue; } - if (isSignal(value)) { + if (isSignal2(value)) { if (key === 'ref') { value.value = element; continue; } - value = trackSignal(value, [ - SubscriptionType.PROP_IMMUTABLE, - vNewNode as fixMeAny, - value, - vNewNode as fixMeAny, - key, - scopedStyleIdPrefix || undefined, - ]); + value = trackSignal2(() => (value as Signal2).value, vNewNode as fixMeAny, key, container); } if (key === dangerouslySetInnerHTML) { diff --git a/packages/qwik/src/core/v2/client/vnode.ts b/packages/qwik/src/core/v2/client/vnode.ts index b781b4186f2..c4d50b12ef7 100644 --- a/packages/qwik/src/core/v2/client/vnode.ts +++ b/packages/qwik/src/core/v2/client/vnode.ts @@ -162,6 +162,7 @@ import { vnode_getDomChildrenWithCorrectNamespacesToInsert, vnode_getElementNamespaceFlags, } from './vnode-namespace'; +import { qwikDebugToString } from '../../debug'; ////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -1475,14 +1476,14 @@ export function vnode_toString( const strings: string[] = []; do { if (vnode_isTextVNode(vnode)) { - strings.push(stringify(vnode_getText(vnode))); + strings.push(qwikDebugToString(vnode_getText(vnode))); } else if (vnode_isVirtualVNode(vnode)) { const idx = vnode[VNodeProps.flags] >>> VNodeFlagsIndex.shift; const attrs: string[] = ['[' + String(idx) + ']']; vnode_getAttrKeys(vnode).forEach((key) => { if (key !== DEBUG_TYPE) { const value = vnode_getAttr(vnode!, key); - attrs.push(' ' + key + '=' + stringify(value)); + attrs.push(' ' + key + '=' + qwikDebugToString(value)); } }); const name = @@ -1498,20 +1499,20 @@ export function vnode_toString( const keys = vnode_getAttrKeys(vnode); keys.forEach((key) => { const value = vnode_getAttr(vnode!, key); - attrs.push(' ' + key + '=' + stringify(value)); + attrs.push(' ' + key + '=' + qwikDebugToString(value)); }); const node = vnode_getNode(vnode) as HTMLElement; if (node) { const vnodeData = (node.ownerDocument as QDocument).qVNodeData?.get(node); if (vnodeData) { - attrs.push(' q:vnodeData=' + stringify(vnodeData)); + attrs.push(' q:vnodeData=' + qwikDebugToString(vnodeData)); } } const domAttrs = node.attributes; for (let i = 0; i < domAttrs.length; i++) { const attr = domAttrs[i]; if (keys.indexOf(attr.name) === -1) { - attrs.push(' ' + attr.name + (attr.value ? '=' + stringify(attr.value) : '')); + attrs.push(' ' + attr.name + (attr.value ? '=' + qwikDebugToString(attr.value) : '')); } } strings.push('<' + tag + attrs.join('') + '>'); @@ -1804,38 +1805,6 @@ export const vnode_getProjectionParentComponent = ( return vHost as VirtualVNode | null; }; -const stringifyPath: any[] = []; -const stringify = (value: any): any => { - stringifyPath.push(value); - try { - if (value === null) { - return 'null'; - } else if (value === undefined) { - return 'undefined'; - } else if (typeof value === 'string') { - return '"' + value + '"'; - } else if (typeof value === 'function') { - if (isQrl(value)) { - return '"' + (value.$chunk$ || '') + '#' + value.$hash$ + '"'; - } else { - return '"' + value.name + '()"'; - } - } else if (vnode_isVNode(value)) { - if (stringifyPath.indexOf(value) !== -1) { - return '*'; - } else { - return '"' + String(value).replaceAll(/\n\s*/gm, '') + '"'; - } - } else if (Array.isArray(value)) { - return '[' + value.map(stringify).join(', ') + ']'; - } else { - return String(value); - } - } finally { - stringifyPath.pop(); - } -}; - const VNodeArray = class VNode extends Array { static createElement( flags: VNodeFlags, diff --git a/packages/qwik/src/core/v2/shared/scheduler.ts b/packages/qwik/src/core/v2/shared/scheduler.ts index 866c129a9ea..d260e83f529 100644 --- a/packages/qwik/src/core/v2/shared/scheduler.ts +++ b/packages/qwik/src/core/v2/shared/scheduler.ts @@ -95,35 +95,37 @@ import { import { isPromise, maybeThen, maybeThenPassError, safeCall } from '../../util/promises'; import type { ValueOrPromise } from '../../util/types'; import type { VirtualVNode } from '../client/types'; -import { vnode_documentPosition, vnode_isVNode } from '../client/vnode'; +import { vnode_documentPosition, vnode_isVNode, vnode_setAttr } from '../client/vnode'; import { vnode_diff } from '../client/vnode-diff'; import { executeComponent2 } from './component-execution'; import type { Container2, HostElement, fixMeAny } from './types'; import { EffectSubscriptionsProp, isSignal2, type EffectSubscriptions } from '../signal/v2-signal'; +import { serializeAttribute } from '../../render/execute-component'; // Turn this on to get debug output of what the scheduler is doing. const DEBUG: boolean = false; export const enum ChoreType { /// MASKS defining three levels of sorting - MACRO /* ***************** */ = 0b111_000, + MACRO /* ***************** */ = 0b111_0000, /* order of elements (not encoded here) */ - MICRO /* ***************** */ = 0b000_111, + MICRO /* ***************** */ = 0b000_1111, /** Ensure tha the QRL promise is resolved before processing next chores in the queue */ - QRL_RESOLVE /* *********** */ = 0b000_000, + QRL_RESOLVE /* *********** */ = 0b000_0000, // TODO(mhevery): COMPUTED should be deleted because it is handled synchronously. - COMPUTED /* ************** */ = 0b000_001, - RESOURCE /* ************** */ = 0b000_010, - TASK /* ****************** */ = 0b000_011, - NODE_DIFF /* ************* */ = 0b000_100, - COMPONENT_SSR /* ********* */ = 0b000_101, - COMPONENT /* ************* */ = 0b000_110, - WAIT_FOR_COMPONENTS /* *** */ = 0b001_000, - JOURNAL_FLUSH /* ********* */ = 0b011_000, - VISIBLE /* *************** */ = 0b100_000, - CLEANUP_VISIBLE /* ******* */ = 0b101_000, - WAIT_FOR_ALL /* ********** */ = 0b111_111, + COMPUTED /* ************** */ = 0b000_0001, + RESOURCE /* ************** */ = 0b000_0010, + TASK /* ****************** */ = 0b000_0011, + NODE_DIFF /* ************* */ = 0b000_0100, + NODE_PROP /* ************* */ = 0b000_0101, + COMPONENT_SSR /* ********* */ = 0b000_0110, + COMPONENT /* ************* */ = 0b000_0111, + WAIT_FOR_COMPONENTS /* *** */ = 0b001_0000, + JOURNAL_FLUSH /* ********* */ = 0b011_0000, + VISIBLE /* *************** */ = 0b100_0000, + CLEANUP_VISIBLE /* ******* */ = 0b101_0000, + WAIT_FOR_ALL /* ********** */ = 0b111_1111, } export interface Chore { @@ -192,12 +194,18 @@ export const createScheduler = ( target: HostElement, value: JSXOutput ): ValueOrPromise; + function schedule( + type: ChoreType.NODE_PROP, + host: HostElement, + prop: string, + value: any + ): ValueOrPromise; function schedule(type: ChoreType.CLEANUP_VISIBLE, task: Task): ValueOrPromise; ///// IMPLEMENTATION ///// function schedule( type: ChoreType, hostOrTask: HostElement | Task | null = null, - targetOrQrl: HostElement | QRL<(...args: any[]) => any> | null = null, + targetOrQrl: HostElement | QRL<(...args: any[]) => any> | string | null = null, payload: any = null ): ValueOrPromise { const runLater: boolean = @@ -214,7 +222,7 @@ export const createScheduler = ( } let chore: Chore = { $type$: type, - $idx$: isTask ? (hostOrTask as Task).$index$ : 0, + $idx$: isTask ? (hostOrTask as Task).$index$ : (typeof targetOrQrl === 'string' ? targetOrQrl : 0), $host$: isTask ? ((hostOrTask as Task).$el$ as fixMeAny) : (hostOrTask as HostElement), $target$: targetOrQrl as any, $payload$: isTask ? hostOrTask : payload, @@ -313,7 +321,7 @@ export const createScheduler = ( const task = chore.$payload$ as Task; cleanupTask(task); break; - case ChoreType.NODE_DIFF: { + case ChoreType.NODE_DIFF: const parentVirtualNode = chore.$target$ as VirtualVNode; let jsx = chore.$payload$ as JSXOutput; if (isSignal2(jsx)) { @@ -321,7 +329,18 @@ export const createScheduler = ( } returnValue = vnode_diff(container as fixMeAny, jsx, parentVirtualNode, null); break; - } + case ChoreType.NODE_PROP: + const virtualNode = chore.$host$ as VirtualVNode; + let value = chore.$payload$ as any; + if (isSignal2(value)) { + value = value.value as any; + } + // TODO(mhevery): Fix this hack + const journal = container.$journal$ as fixMeAny; + const property = chore.$idx$ as string; + value = serializeAttribute(property, value); + vnode_setAttr(journal, virtualNode, property, value); + break; } return maybeThenPassError(returnValue, (value) => { DEBUG && debugTrace('execute.DONE', null, currentChore, choreQueue); diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index a1c45d1c301..49d23be1c95 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -16,13 +16,12 @@ import { pad, qwikDebugToString } from '../../debug'; import { assertDefined, assertFalse, assertTrue } from '../../error/assert'; import { type QRLInternal } from '../../qrl/qrl-class'; import type { QRL } from '../../qrl/qrl.public'; -import type { JSXOutput } from '../../render/jsx/types/jsx-node'; import { trackSignal2, - tryGetInvokeContext, - type InvokeContext + tryGetInvokeContext } from '../../use/use-core'; import { Task, TaskFlags, isTask } from '../../use/use-task'; +import { ELEMENT_PROPS, OnRenderProp } from '../../util/markers'; import { isPromise } from '../../util/promises'; import { qDev } from '../../util/qdev'; import type { VNode } from '../client/types'; @@ -82,7 +81,6 @@ export type Effect = Task | VNode | Signal2; * is to clear its subscriptions so that the effect can re add new set of subscriptions. In order to * clear the subscriptions we need to store them here. * - * (For performance reasons we also save `InvokeContext` so that we can avoid re-creating it.) * * Imagine you have effect such as: * @@ -106,11 +104,19 @@ export type Effect = Task | VNode | Signal2; * Both `signalA` as well as `signalB` will have a reference to `subscription` to the so that the * effect can be scheduled if either `signalA` or `signalB` triggers. The `subscription1` is shared * between the signals. + * + * The second position `string|boolean` store the property name of the effect. + * - property name of the VNode + * - `true` if component. + * - `false` if VNode update. (not component) */ -export type EffectSubscriptions = [Effect, InvokeContext | null, ...Signal2[]]; +export type EffectSubscriptions = [ + Effect, // EffectSubscriptionsProp.EFFECT + string | boolean, // EffectSubscriptionsProp.PROPERTY + ...Signal2[]]; export const enum EffectSubscriptionsProp { EFFECT = 0, - CONTEXT = 1, + PROPERTY = 1, } class Signal2 implements ISignal2 { @@ -125,6 +131,7 @@ class Signal2 implements ISignal2 { constructor(container: Container2 | null, value: T) { this.$container$ = container; this.$untrackedValue$ = value; + DEBUG && log('new', this); } get untrackedValue() { @@ -144,7 +151,13 @@ class Signal2 implements ISignal2 { 'Do not use signals across containers' ); } - const effectSubscriber = ctx.$effectSubscriber$; + let effectSubscriber = ctx.$effectSubscriber$; + if (!effectSubscriber && ctx.$hostElement$) { + const host: VNode | null = ctx.$hostElement$ as any; + if (host) { + effectSubscriber = [host, true /* component */]; + } + } if (effectSubscriber) { const effects = (this.$effects$ ||= []); // Let's make sure that we have a reference to this effect. @@ -174,10 +187,12 @@ class Signal2 implements ISignal2 { let signal: Signal2 = this; const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { const effect = effectSubscriptions[EffectSubscriptionsProp.EFFECT]; + const property = effectSubscriptions[EffectSubscriptionsProp.PROPERTY]; assertDefined(this.$container$, 'Scheduler must be defined.'); if (isTask(effect)) { effect.$flags$ |= TaskFlags.DIRTY; DEBUG && log('schedule.effect.task', pad('\n' + String(effect), ' ')); + // TODO(mhevery): We should check if visible/resource task and scheduled differently. this.$container$.$scheduler$(ChoreType.TASK, effectSubscriptions as fixMeAny); } else if (effect instanceof Signal2) { // we don't schedule ComputedSignal/DerivedSignal directly, instead we invalidate it and @@ -186,7 +201,7 @@ class Signal2 implements ISignal2 { // TODO(misko): ensure that the computed signal's QRL is resolved. // If not resolved scheduled it to be resolved. } - (effect as ComputedSignal2 | DerivedSignal).$invalid$ = true; + (effect as ComputedSignal2 | DerivedSignal2).$invalid$ = true; const previousSignal = signal; try { signal = effect; @@ -194,11 +209,19 @@ class Signal2 implements ISignal2 { } finally { signal = previousSignal; } - } else { + } else if (property === true) { + const host: HostElement = effect as any; + const qrl = this.$container$.getHostProp any>>(host, OnRenderProp); + assertDefined(qrl, 'Component must have QRL'); + const props = this.$container$.getHostProp(host, ELEMENT_PROPS); + this.$container$.$scheduler$(ChoreType.COMPONENT, host, qrl, props); + } else if (property === false) { const host: HostElement = effect as any; const target = host; - DEBUG && log('schedule.effect.node_diff', pad('\n' + String(effect), ' ')); this.$container$.$scheduler$(ChoreType.NODE_DIFF, host, target, signal as fixMeAny); + } else { + const host: HostElement = effect as any; + this.$container$.$scheduler$(ChoreType.NODE_PROP, host, property, signal as fixMeAny); } }; this.$effects$.forEach(scheduleEffect); @@ -297,7 +320,7 @@ class ComputedSignal2 extends Signal2 { const ctx = tryGetInvokeContext(); assertDefined(computeQrl, 'Signal is marked as dirty, but no compute function is provided.'); const previousEffectSubscription = ctx?.$effectSubscriber$; - ctx && (ctx.$effectSubscriber$ = [this, null]); + ctx && (ctx.$effectSubscriber$ = [this, false]); assertTrue( !!computeQrl.resolved, 'Computed signals must run sync. Expected the QRL to be resolved at this point.' @@ -328,7 +351,7 @@ class ComputedSignal2 extends Signal2 { } } -export class DerivedSignal extends Signal2 { +export class DerivedSignal2 extends Signal2 { $args$: any[]; $fn$: (...args: any[]) => T; $fnStr$: string | null; @@ -380,7 +403,7 @@ export class DerivedSignal extends Signal2 { if (!this.$invalid$) { return false; } - this.$untrackedValue$ = trackSignal2(() => this.$fn$(...this.$args$), this, this.$container$!); + this.$untrackedValue$ = trackSignal2(() => this.$fn$(...this.$args$), this, false, this.$container$!); } // Getters don't get inherited diff --git a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx index 16740807b2f..e917f4a90d4 100644 --- a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx @@ -15,11 +15,11 @@ import type { Signal as SignalType } from '../../state/signal'; import { untrack } from '../../use/use-core'; import { useSignal } from '../../use/use-signal'; -const debug = true; //true; +const debug = false; //true; Error.stackTraceLimit = 100; describe.each([ - // { render: ssrRenderToDom }, // + { render: ssrRenderToDom }, // { render: domRender }, // ])('$render.name: useSignal', ({ render }) => { it('should update value', async () => { @@ -87,7 +87,7 @@ describe.each([ ); }); - it.only('should update from JSX', async () => { + it('should update from JSX', async () => { const Child = component$(() => { return ( From dbbf2769584557a3c492e9dbd917e138e7495410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Tue, 9 Jul 2024 22:20:35 +0200 Subject: [PATCH 16/89] WIP: serialization refactored, more work needed --- packages/qwik/src/core/use/use-core.ts | 14 +- .../core/v2/shared/shared-serialization.ts | 218 ++++++++---------- packages/qwik/src/core/v2/signal/v2-signal.ts | 23 +- .../qwik/src/core/v2/ssr/ssr-render-jsx.ts | 19 +- .../qwik/src/core/v2/tests/container.spec.tsx | 2 +- .../src/core/v2/tests/use-signal.spec.tsx | 6 +- 6 files changed, 134 insertions(+), 148 deletions(-) diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index 412b9b52408..6421239bffe 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -255,12 +255,22 @@ export const trackSignal = (signal: Signal, sub: Subscriber): T => { return invoke(trackInvocation, () => signal.value); }; -export const trackSignal2 = (fn: () => T, sub: Effect, property: string | boolean, container: Container2): T => { +/** + * + * @param fn + * @param subscriber + * @param property `true` - subscriber is component + * `false` - subscriber is VNode + * `string` - subscriber is property + * @param container + * @returns + */ +export const trackSignal2 = (fn: () => T, subscriber: Effect, property: string | boolean, container: Container2): T => { console.log(">>>>>>>>>> PROPERTY", property); const previousSubscriber = trackInvocation.$effectSubscriber$; const previousContainer = trackInvocation.$container2$; try { - trackInvocation.$effectSubscriber$ = [sub, property]; + trackInvocation.$effectSubscriber$ = [subscriber, property]; trackInvocation.$container2$ = container; return invoke(trackInvocation, fn); } finally { diff --git a/packages/qwik/src/core/v2/shared/shared-serialization.ts b/packages/qwik/src/core/v2/shared/shared-serialization.ts index 124f06f0be3..cb471e34a31 100644 --- a/packages/qwik/src/core/v2/shared/shared-serialization.ts +++ b/packages/qwik/src/core/v2/shared/shared-serialization.ts @@ -30,14 +30,7 @@ import { type LocalSubscriptionManager, type Subscriber, } from '../../state/common'; -import { QObjectManagerSymbol, _CONST_PROPS, _VAR_PROPS } from '../../state/constants'; -import { - SignalDerived, - SignalImpl, - SignalWrapper, - isSignal, - type Signal, -} from '../../state/signal'; +import { _CONST_PROPS, _VAR_PROPS } from '../../state/constants'; import { getOrCreateProxy, isStore } from '../../state/store'; import { Task, type ResourceReturnInternal } from '../../use/use-task'; import { throwErrorAndStop } from '../../util/log'; @@ -47,6 +40,13 @@ import type { DomContainer } from '../client/dom-container'; import { vnode_getNode, vnode_isVNode, vnode_locate } from '../client/vnode'; import type { SymbolToChunkResolver } from '../ssr/ssr-types'; import { ELEMENT_ID } from '../../util/markers'; +import { + ComputedSignal2, + DerivedSignal2, + EffectSubscriptionsProp, + Signal2, + type EffectSubscriptions, +} from '../signal/v2-signal'; const deserializedProxyMap = new WeakMap(); @@ -199,7 +199,7 @@ function upgradePropsWithDerivedSignal( target: Record, property: string | symbol | number ): any { - const immutable: Record> = {}; + const immutable: Record> = {}; for (const key in target) { if (Object.prototype.hasOwnProperty.call(target, key)) { const value = target[key]; @@ -207,7 +207,7 @@ function upgradePropsWithDerivedSignal( typeof value === 'string' && value.charCodeAt(0) === SerializationConstant.DerivedSignal_VALUE ) { - const derivedSignal = (immutable[key] = allocate(value) as SignalDerived); + const derivedSignal = (immutable[key] = allocate(value) as DerivedSignal2); Object.defineProperty(target, key, { get() { return derivedSignal.value; @@ -266,7 +266,7 @@ const inflate = (container: DomContainer, target: any, needsInflationData: strin task.$qrl$ = inflateQRL(container, parseQRL(restString())); const taskState = restString(); task.$state$ = taskState - ? (container.$getObjectById$(taskState) as Signal) + ? (container.$getObjectById$(taskState) as Signal2) : undefined; break; case SerializationConstant.Resource_VALUE: @@ -274,39 +274,38 @@ const inflate = (container: DomContainer, target: any, needsInflationData: strin case SerializationConstant.Component_VALUE: inflateQRL(container, target[SERIALIZABLE_STATE][0]); break; - case SerializationConstant.DerivedSignal_VALUE: - const derivedSignal = target as SignalDerived; - derivedSignal.$func$ = container.getSyncFn(restInt()); - const args: any[] = (derivedSignal.$args$ = []); - while (restIdx < rest.length) { - args.push(container.$getObjectById$(restInt())); - } - break; case SerializationConstant.Store_VALUE: break; case SerializationConstant.Signal_VALUE: - const signal = target as SignalImpl; + const signal = target as Signal2; + signal.$container$ = container; const semiIdx = rest.indexOf(';'); - const manager = (signal[QObjectManagerSymbol] = container.$subsManager$.$createManager$()); let signalValue = container.$getObjectById$( rest.substring(1, semiIdx === -1 ? rest.length : semiIdx) ); if (vnode_isVNode(signalValue)) { signalValue = vnode_getNode(signalValue); } - signal.untrackedValue = signalValue; - if (semiIdx > 0) { - subscriptionManagerFromString( - manager, - rest.substring(semiIdx + 1), - container.$getObjectById$ - ); + signal.$untrackedValue$ = signalValue; + signal.$effects$ = deserializeDerivedSignal2(container.$getObjectById$, rest); + break; + case SerializationConstant.DerivedSignal_VALUE: + const derivedSignal = target as DerivedSignal2; + derivedSignal.$container$ = container; + derivedSignal.$func$ = container.getSyncFn(restInt()); + const args: any[] = (derivedSignal.$args$ = []); + while (restIdx < rest.length) { + args.push(container.$getObjectById$(restInt())); } break; - case SerializationConstant.SignalWrapper_VALUE: - const signalWrapper = target as SignalWrapper, string>; - signalWrapper.ref = container.$getObjectById$(restInt()) as Record; - signalWrapper.prop = restString(); + case SerializationConstant.ComputedSignal_VALUE: + const computedSignal = target as ComputedSignal2; + computedSignal.$container$ = container; + computedSignal.$untrackedValue$ = container.$getObjectById$(restInt()) as Record< + string, + unknown + >; + computedSignal.$computeQrl$ = container.$getObjectById$(restInt()) as QRLInternal<() => any>; break; case SerializationConstant.Error_VALUE: Object.assign(target, container.$getObjectById$(restInt())); @@ -390,12 +389,12 @@ const allocate = (value: string): any => { return new Error(); case SerializationConstant.Component_VALUE: return componentQrl(parseQRL(value) as any); - case SerializationConstant.DerivedSignal_VALUE: - return new SignalDerived(null!, null!, null!); case SerializationConstant.Signal_VALUE: - return new SignalImpl(null!, null!, 0); - case SerializationConstant.SignalWrapper_VALUE: - return new SignalWrapper(null!, null!); + return new Signal2(null!, 0); + case SerializationConstant.DerivedSignal_VALUE: + return new DerivedSignal2(null!, null!, null!, null!); + case SerializationConstant.ComputedSignal_VALUE: + return new ComputedSignal2(null!, null!); case SerializationConstant.NotFinite_VALUE: const type = value.substring(1); const isNaN = type.length === 0; @@ -526,7 +525,7 @@ export interface SerializationContext { $roots$: unknown[]; - $addSyncFn$($funcStr$: string | undefined, argsCount: number, fn: Function): number; + $addSyncFn$($funcStr$: string | null, argsCount: number, fn: Function): number; $proxyMap$: ObjToProxyMap; @@ -606,8 +605,8 @@ export const createSerializationContext = ( }, $proxyMap$, $syncFns$: syncFns, - $addSyncFn$: (funcStr: string | undefined, argCount: number, fn: Function) => { - const isFullFn = funcStr === undefined; + $addSyncFn$: (funcStr: string | null, argCount: number, fn: Function) => { + const isFullFn = funcStr == null; if (isFullFn) { funcStr = fn.toString(); } @@ -706,22 +705,9 @@ export const createSerializationContext = ( }); setSerializableDataRootId($addRoot$, obj, tuples); discoveredValues.push(tuples); - } else if (isSignal(obj)) { - if (obj instanceof SignalImpl) { - discoveredValues.push(obj.untrackedValue); - const manager = getSubscriptionManager(obj); - manager?.$subs$.forEach((sub) => { - discoveredValues.push(sub[SubscriptionProp.HOST]); - // prevent infinity loop, don't add the same object as the current one - if (obj !== sub[SubscriptionProp.SIGNAL]) { - discoveredValues.push(sub[SubscriptionProp.SIGNAL]); - } - }); - } else if (obj instanceof SignalWrapper) { - discoveredValues.push(obj.ref); - } - // const manager = obj[QObjectManagerSymbol]; - // discoveredValues.push(...manager.$subs$); + } else if (obj instanceof Signal2) { + discoveredValues.push(obj.$untrackedValue$); + // TODO(mhevery): should scan the QRLs??? } else if (obj instanceof Task) { discoveredValues.push(obj.$el$, obj.$qrl$, obj.$state$); } else if (NodeConstructor && obj instanceof NodeConstructor) { @@ -872,25 +858,24 @@ function serialize(serializationContext: SerializationContext): void { serializationContext.$proxyMap$, $addRoot$ ); - } else if (value instanceof SignalImpl) { - const manager = getSubscriptionManager(value)!; - const subscriptions = subscriptionManagerToString(manager, $addRoot$); - writeString( - SerializationConstant.Signal_CHAR + + } else if (value instanceof Signal2) { + console.log('SERIALIZE', String(value)); + if (value instanceof DerivedSignal2) { + const serializedDerivedSignal2 = serializeDerivedSignal2( + serializationContext, + value, + $addRoot$ + ); + writeString(serializedDerivedSignal2); + } else if (value instanceof ComputedSignal2) { + writeString(SerializationConstant.ComputedSignal_CHAR + $addRoot$(value.$computeQrl$)); + } else { + writeString( + SerializationConstant.Signal_CHAR + $addRoot$(value.untrackedValue) + - (subscriptions === '' ? '' : ';' + subscriptions) - ); - } else if (value instanceof SignalDerived) { - const serializedSignalDerived = serializeSignalDerived( - serializationContext, - value, - $addRoot$ - ); - writeString(serializedSignalDerived); - } else if (value instanceof SignalWrapper) { - writeString( - SerializationConstant.SignalWrapper_CHAR + $addRoot$(value.ref) + ' ' + value.prop - ); + serializeSubscriptions($addRoot$, value.$effects$) + ); + } } else if (value instanceof URL) { writeString(SerializationConstant.URL_CHAR + value.href); } else if (value instanceof Date) { @@ -1007,6 +992,22 @@ function serialize(serializationContext: SerializationContext): void { writeValue(serializationContext.$roots$, -1); } +function serializeSubscriptions( + addRoot: (obj: unknown) => number, + effects: EffectSubscriptions[] | null +): string { + let data = ''; + if (effects) { + for (let i = 0; i < effects.length; i++) { + const effectSubscription = effects[i]; + const effect = effectSubscription[EffectSubscriptionsProp.EFFECT]; + const prop = effectSubscription[EffectSubscriptionsProp.PROPERTY]; + data += ' ' + addRoot(effect) + (typeof prop === 'string' ? ':' + prop : ''); + } + } + return data; +} + function serializeProxy( value: any, proxy: any, @@ -1055,9 +1056,9 @@ function serializeObjectProperties( } } -function serializeSignalDerived( +function serializeDerivedSignal2( serializationContext: SerializationContext, - value: SignalDerived, + value: DerivedSignal2, $addRoot$: (obj: unknown) => number ) { // if value is an object then we need to wrap this in () @@ -1073,6 +1074,16 @@ function serializeSignalDerived( return SerializationConstant.DerivedSignal_CHAR + syncFnId + (args.length ? ' ' + args : ''); } +function deserializeDerivedSignal2( + getObjectById: (id: number | string) => any, + rest: string, +): EffectSubscriptions { + const [effectId, prop] = rest.split(' '); + const effect = getObjectById(effectId); + console.log('DESERIALIZE', effectId, prop) + return [[effect, prop]]; +} + function setSerializableDataRootId($addRoot$: (value: any) => number, obj: object, value: any) { (obj as any)[SERIALIZABLE_ROOT_ID] = $addRoot$(value); } @@ -1083,41 +1094,6 @@ function getSerializableDataRootId(value: object) { return id; } -function subscriptionManagerToString( - subscriptionManager: LocalSubscriptionManager, - $addRoot$: (obj: any) => number -) { - const data: string[] = []; - for (const sub of subscriptionManager.$subs$) { - data.push( - sub.map((val, propId) => (propId === SubscriptionProp.TYPE ? val : $addRoot$(val))).join(' ') - ); - } - return data.join(';'); -} - -export function subscriptionManagerFromString( - subscriptionManager: LocalSubscriptionManager, - value: string, - getObjectById: (id: number) => any -) { - const subs = value.split(';'); - for (let k = 0; k < subs.length; k++) { - const sub = subs[k]; - if (!sub) { - // skip empty strings - continue; - } - const subscription = sub.split(' ') as (string | number)[]; - subscription[0] = parseInt(subscription[0] as string); - for (let i = 1; i < subscription.length; i++) { - subscription[i] = getObjectById(subscription[i] as number); - } - const prop = subscription.pop() as string | undefined; - subscriptionManager.$addSub$(subscription as any as Subscriber, prop); - } -} - export function qrlToString( serializationContext: SerializationContext, value: QRLInternal | SyncQRLInternal @@ -1147,7 +1123,7 @@ export function qrlToString( } else { const fn = value.resolved as Function; chunk = ''; - symbol = String(serializationContext.$addSyncFn$(undefined, 0, fn)); + symbol = String(serializationContext.$addSyncFn$(null, 0, fn)); } let qrlStringInline = `${chunk}#${symbol}`; @@ -1213,7 +1189,7 @@ const frameworkType = (obj: any) => { return ( (typeof obj === 'object' && obj !== null && - (obj instanceof SignalImpl || obj instanceof Task || isJSXNode(obj))) || + (obj instanceof Signal2 || obj instanceof Task || isJSXNode(obj))) || isQrl(obj) ); }; @@ -1263,12 +1239,12 @@ export const enum SerializationConstant { Resource_VALUE = /* ---------------------*/ 0x12, Component_CHAR = /* ----------------- */ '\u0013', Component_VALUE = /* ------------------- */ 0x13, - DerivedSignal_CHAR = /* ------------- */ '\u0014', - DerivedSignal_VALUE = /* --------------- */ 0x14, - Signal_CHAR = /* -------------------- */ '\u0015', - Signal_VALUE = /* ---------------------- */ 0x15, - SignalWrapper_CHAR = /* ------------- */ '\u0016', - SignalWrapper_VALUE = /* --------------- */ 0x16, + Signal_CHAR = /* -------------------- */ '\u0014', + Signal_VALUE = /* ---------------------- */ 0x14, + DerivedSignal_CHAR = /* ------------- */ '\u0015', + DerivedSignal_VALUE = /* --------------- */ 0x15, + ComputedSignal_CHAR = /* ------------ */ '\u0016', + ComputedSignal_VALUE = /* -------------- */ 0x16, Store_CHAR = /* --------------------- */ '\u0017', Store_VALUE = /* ----------------------- */ 0x17, FormData_CHAR = /* ------------------ */ '\u0018', @@ -1358,8 +1334,8 @@ export const codeToName = (code: number) => { return 'Store'; case SerializationConstant.Signal_VALUE: return 'Signal'; - case SerializationConstant.SignalWrapper_VALUE: - return 'SignalWrapper'; + case SerializationConstant.ComputedSignal_VALUE: + return 'ComputedSignal'; case SerializationConstant.NotFinite_VALUE: return 'NotFinite'; case SerializationConstant.URLSearchParams_VALUE: diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index 49d23be1c95..c729be92b1f 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -117,16 +117,17 @@ export type EffectSubscriptions = [ export const enum EffectSubscriptionsProp { EFFECT = 0, PROPERTY = 1, + FIRST_BACK_REF = 2, } -class Signal2 implements ISignal2 { - protected $untrackedValue$: T; +export class Signal2 implements ISignal2 { + $untrackedValue$: T; /** Store a list of effects which are dependent on this signal. */ // TODO perf: use a set? - protected $effects$: null | EffectSubscriptions[] = null; + $effects$: null | EffectSubscriptions[] = null; - protected $container$: Container2 | null = null; + $container$: Container2 | null = null; constructor(container: Container2 | null, value: T) { this.$container$ = container; @@ -259,7 +260,7 @@ function ensureContains(array: any[], value: any) { * * The value is available synchronously, but the computation is done lazily. */ -class ComputedSignal2 extends Signal2 { +export class ComputedSignal2 extends Signal2 { /** * The compute function is stored here. * @@ -353,8 +354,8 @@ class ComputedSignal2 extends Signal2 { export class DerivedSignal2 extends Signal2 { $args$: any[]; - $fn$: (...args: any[]) => T; - $fnStr$: string | null; + $func$: (...args: any[]) => T; + $funcStr$: string | null; // We need a separate flag to know when the computation needs running because // we need the old value to know if effects need running after computation @@ -363,8 +364,8 @@ export class DerivedSignal2 extends Signal2 { constructor(container: Container2 | null, fn: (...args: any[]) => T, args: any[], fnStr: string | null) { super(container, NEEDS_COMPUTATION); this.$args$ = args; - this.$fn$ = fn; - this.$fnStr$ = fnStr; + this.$func$ = fn; + this.$funcStr$ = fnStr; } $invalidate$() { @@ -392,7 +393,7 @@ export class DerivedSignal2 extends Signal2 { get untrackedValue() { if (this.$invalid$ && !this.$container$) { // This is a hack to handle isValidJSXChild. Unsure why this is needed. - return this.$fn$(...this.$args$); + return this.$func$(...this.$args$); } this.$computeIfNeeded$(); assertFalse(this.$untrackedValue$ === NEEDS_COMPUTATION, 'Invalid state') @@ -403,7 +404,7 @@ export class DerivedSignal2 extends Signal2 { if (!this.$invalid$) { return false; } - this.$untrackedValue$ = trackSignal2(() => this.$fn$(...this.$args$), this, false, this.$container$!); + this.$untrackedValue$ = trackSignal2(() => this.$func$(...this.$args$), this, false, this.$container$!); } // Getters don't get inherited diff --git a/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts index a7bff40ee16..35b98905665 100644 --- a/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts +++ b/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts @@ -8,8 +8,6 @@ import { Slot } from '../../render/jsx/slot.public'; import type { JSXNode, JSXOutput } from '../../render/jsx/types/jsx-node'; import type { JSXChildren } from '../../render/jsx/types/jsx-qwik-attributes'; import { SubscriptionType } from '../../state/common'; -import { SignalDerived, isSignal } from '../../state/signal'; -import { trackSignal } from '../../use/use-core'; import { EMPTY_ARRAY } from '../../util/flyweight'; import { throwErrorAndStop } from '../../util/log'; import { @@ -41,6 +39,8 @@ import { type SSRStreamChildren, } from '../../render/jsx/utils.public'; import { isAsyncGenerator } from '../../util/async-generator'; +import { DerivedSignal2, isSignal2 } from '../signal/v2-signal'; +import { trackSignal2 } from '../../use/use-core'; class SetScopedStyle { constructor(public $scopedStyle$: string | null) {} @@ -139,14 +139,13 @@ function processJSXNode( for (let i = value.length - 1; i >= 0; i--) { enqueue(value[i]); } - } else if (isSignal(value)) { + } else if (isSignal2(value)) { ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.DerivedSignal] : EMPTY_ARRAY); const signalNode = ssr.getLastNode() as fixMeAny; // TODO(mhevery): It is unclear to me why we need to serialize host for SignalDerived. // const host = ssr.getComponentFrame(0)!.componentNode as fixMeAny; - const host = signalNode; enqueue(ssr.closeFragment); - enqueue(trackSignal(value, [SubscriptionType.TEXT_MUTABLE, host, value, signalNode], ssr)); + enqueue(trackSignal2(() => (value.value as any), signalNode, false, ssr)); } else if (isPromise(value)) { ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.Awaited] : EMPTY_ARRAY); enqueue(ssr.closeFragment); @@ -221,7 +220,7 @@ function processJSXNode( ssr.openProjection(projectionAttrs); const host = componentFrame.componentNode; const node = ssr.getLastNode(); - const slotName = getSlotName(host, jsx); + const slotName = getSlotName(host, jsx, ssr); projectionAttrs.push(QSlot, slotName); enqueue(new SetScopedStyle(styleScoped)); enqueue(ssr.closeProjection); @@ -378,7 +377,7 @@ export function toSsrAttrs( continue; } - if (isSignal(value)) { + if (isSignal2(value)) { // write signal as is. We will track this signal inside `writeAttrs` if (isClassAttr(key)) { // additionally append styleScopedId for class attr @@ -490,12 +489,12 @@ function addPreventDefaultEventToSerializationContext( } } -function getSlotName(host: ISsrNode, jsx: JSXNode): string { +function getSlotName(host: ISsrNode, jsx: JSXNode, ssr: SSRContainer): string { const constProps = jsx.constProps; if (constProps && typeof constProps == 'object' && 'name' in constProps) { const constValue = constProps.name; - if (constValue instanceof SignalDerived) { - return trackSignal(constValue, [SubscriptionType.HOST, host as fixMeAny]); + if (constValue instanceof DerivedSignal2) { + return trackSignal2(() => constValue.value, host as fixMeAny, false, ssr); } } return (jsx.props.name as string) || QDefaultSlot; diff --git a/packages/qwik/src/core/v2/tests/container.spec.tsx b/packages/qwik/src/core/v2/tests/container.spec.tsx index 55b57411f95..b339ddad56c 100644 --- a/packages/qwik/src/core/v2/tests/container.spec.tsx +++ b/packages/qwik/src/core/v2/tests/container.spec.tsx @@ -371,7 +371,7 @@ describe('serializer v2', () => { }); }); - describe('SignalWrapperSerializer, / ' + SerializationConstant.SignalWrapper_CHAR, () => { + describe('SignalWrapperSerializer, / ' + SerializationConstant.ComputedSignal_CHAR, () => { it.todo('should serialize and deserialize', () => { /// }); diff --git a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx index e917f4a90d4..7f6897433d2 100644 --- a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx @@ -15,14 +15,14 @@ import type { Signal as SignalType } from '../../state/signal'; import { untrack } from '../../use/use-core'; import { useSignal } from '../../use/use-signal'; -const debug = false; //true; +const debug = true; //true; Error.stackTraceLimit = 100; describe.each([ { render: ssrRenderToDom }, // - { render: domRender }, // + // { render: domRender }, // ])('$render.name: useSignal', ({ render }) => { - it('should update value', async () => { + it.only('should update value', async () => { const Counter = component$((props: { initial: number }) => { const count = useSignal(props.initial); return ; From 70c32f6fb3ca7c79a98618920484463d055a027e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Fri, 12 Jul 2024 11:04:14 +0200 Subject: [PATCH 17/89] WIP: most of the use-signal.spec tests pass --- packages/qwik/src/core/use/use-core.ts | 2 +- .../qwik/src/core/v2/client/dom-container.ts | 2 +- .../qwik/src/core/v2/client/vnode-diff.ts | 6 +- .../core/v2/shared/shared-serialization.ts | 105 ++++++++++-------- packages/qwik/src/core/v2/signal/v2-signal.ts | 20 ++-- .../qwik/src/core/v2/ssr/ssr-render-jsx.ts | 6 +- 6 files changed, 77 insertions(+), 64 deletions(-) diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index 6421239bffe..5501067ebce 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -265,7 +265,7 @@ export const trackSignal = (signal: Signal, sub: Subscriber): T => { * @param container * @returns */ -export const trackSignal2 = (fn: () => T, subscriber: Effect, property: string | boolean, container: Container2): T => { +export const trackSignal2 = (fn: () => T, subscriber: Effect, property: string, container: Container2): T => { console.log(">>>>>>>>>> PROPERTY", property); const previousSubscriber = trackInvocation.$effectSubscriber$; const previousContainer = trackInvocation.$container2$; diff --git a/packages/qwik/src/core/v2/client/dom-container.ts b/packages/qwik/src/core/v2/client/dom-container.ts index a4683c25637..f7ba3dfcf62 100644 --- a/packages/qwik/src/core/v2/client/dom-container.ts +++ b/packages/qwik/src/core/v2/client/dom-container.ts @@ -303,7 +303,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer, if (typeof id === 'string') { id = parseFloat(id); } - assertTrue(id < this.$rawStateData$.length, 'Invalid reference'); + assertTrue(id < this.$rawStateData$.length, `Invalid reference: ${id} < ${this.$rawStateData$.length}`); return this.stateData[id]; }; diff --git a/packages/qwik/src/core/v2/client/vnode-diff.ts b/packages/qwik/src/core/v2/client/vnode-diff.ts index 78795adbd50..b4b43078ecf 100644 --- a/packages/qwik/src/core/v2/client/vnode-diff.ts +++ b/packages/qwik/src/core/v2/client/vnode-diff.ts @@ -86,7 +86,7 @@ import { type VNodeJournal, } from './vnode'; import { getNewElementNamespaceData } from './vnode-namespace'; -import { DerivedSignal2, isSignal2 } from '../signal/v2-signal'; +import { DerivedSignal2, EffectProperty, isSignal2 } from '../signal/v2-signal'; import type { Signal2 } from '../signal/v2-signal.public'; export type ComponentQueue = Array; @@ -182,7 +182,7 @@ export const vnode_diff = ( trackSignal2( () => jsxValue.value, vCurrent || (vNewNode as fixMeAny), // This should be host, but not sure why - false, + EffectProperty.VNODE, container ), true @@ -489,7 +489,7 @@ export const vnode_diff = ( if (constProps && typeof constProps == 'object' && 'name' in constProps) { const constValue = constProps.name; if (constValue instanceof DerivedSignal2) { - return trackSignal2(() => constValue.value, vHost as fixMeAny, true, container); + return trackSignal2(() => constValue.value, vHost as fixMeAny, EffectProperty.COMPONENT, container); } } return jsxValue.props.name || QDefaultSlot; diff --git a/packages/qwik/src/core/v2/shared/shared-serialization.ts b/packages/qwik/src/core/v2/shared/shared-serialization.ts index cb471e34a31..ea943d26e9f 100644 --- a/packages/qwik/src/core/v2/shared/shared-serialization.ts +++ b/packages/qwik/src/core/v2/shared/shared-serialization.ts @@ -47,6 +47,7 @@ import { Signal2, type EffectSubscriptions, } from '../signal/v2-signal'; +import type { fixMeAny } from './types'; const deserializedProxyMap = new WeakMap(); @@ -277,35 +278,13 @@ const inflate = (container: DomContainer, target: any, needsInflationData: strin case SerializationConstant.Store_VALUE: break; case SerializationConstant.Signal_VALUE: - const signal = target as Signal2; - signal.$container$ = container; - const semiIdx = rest.indexOf(';'); - let signalValue = container.$getObjectById$( - rest.substring(1, semiIdx === -1 ? rest.length : semiIdx) - ); - if (vnode_isVNode(signalValue)) { - signalValue = vnode_getNode(signalValue); - } - signal.$untrackedValue$ = signalValue; - signal.$effects$ = deserializeDerivedSignal2(container.$getObjectById$, rest); + deserializeSignal2(target as Signal2, container, rest, false, false); break; case SerializationConstant.DerivedSignal_VALUE: - const derivedSignal = target as DerivedSignal2; - derivedSignal.$container$ = container; - derivedSignal.$func$ = container.getSyncFn(restInt()); - const args: any[] = (derivedSignal.$args$ = []); - while (restIdx < rest.length) { - args.push(container.$getObjectById$(restInt())); - } + deserializeSignal2(target as Signal2, container, rest, true, false); break; case SerializationConstant.ComputedSignal_VALUE: - const computedSignal = target as ComputedSignal2; - computedSignal.$container$ = container; - computedSignal.$untrackedValue$ = container.$getObjectById$(restInt()) as Record< - string, - unknown - >; - computedSignal.$computeQrl$ = container.$getObjectById$(restInt()) as QRLInternal<() => any>; + deserializeSignal2(target as Signal2, container, rest, false, true); break; case SerializationConstant.Error_VALUE: Object.assign(target, container.$getObjectById$(restInt())); @@ -861,20 +840,23 @@ function serialize(serializationContext: SerializationContext): void { } else if (value instanceof Signal2) { console.log('SERIALIZE', String(value)); if (value instanceof DerivedSignal2) { - const serializedDerivedSignal2 = serializeDerivedSignal2( - serializationContext, - value, - $addRoot$ + writeString( + SerializationConstant.DerivedSignal_CHAR + + serializeDerivedFn(serializationContext, value, $addRoot$) + + ';' + + $addRoot$(value.$untrackedValue$) + + serializeEffectSubs($addRoot$, value.$effects$) ); - writeString(serializedDerivedSignal2); } else if (value instanceof ComputedSignal2) { - writeString(SerializationConstant.ComputedSignal_CHAR + $addRoot$(value.$computeQrl$)); + writeString(SerializationConstant.ComputedSignal_CHAR + + qrlToString(serializationContext, value.$computeQrl$) + + ';' + + ($addRoot$(value.$untrackedValue$)) + + serializeEffectSubs($addRoot$, value.$effects$)) } else { - writeString( - SerializationConstant.Signal_CHAR + - $addRoot$(value.untrackedValue) + - serializeSubscriptions($addRoot$, value.$effects$) - ); + writeString(SerializationConstant.Signal_CHAR + + ($addRoot$(value.$untrackedValue$)) + + (serializeEffectSubs($addRoot$, value.$effects$))); } } else if (value instanceof URL) { writeString(SerializationConstant.URL_CHAR + value.href); @@ -992,7 +974,7 @@ function serialize(serializationContext: SerializationContext): void { writeValue(serializationContext.$roots$, -1); } -function serializeSubscriptions( +function serializeEffectSubs( addRoot: (obj: unknown) => number, effects: EffectSubscriptions[] | null ): string { @@ -1002,7 +984,10 @@ function serializeSubscriptions( const effectSubscription = effects[i]; const effect = effectSubscription[EffectSubscriptionsProp.EFFECT]; const prop = effectSubscription[EffectSubscriptionsProp.PROPERTY]; - data += ' ' + addRoot(effect) + (typeof prop === 'string' ? ':' + prop : ''); + data += ';' + addRoot(effect) + ' ' + prop; + for (let j = EffectSubscriptionsProp.FIRST_BACK_REF; j < effectSubscription.length; j++) { + data += ' ' + addRoot(effectSubscription[j]); + } } } return data; @@ -1056,7 +1041,7 @@ function serializeObjectProperties( } } -function serializeDerivedSignal2( +function serializeDerivedFn( serializationContext: SerializationContext, value: DerivedSignal2, $addRoot$: (obj: unknown) => number @@ -1071,17 +1056,41 @@ function serializeDerivedSignal2( value.$func$ ); const args = value.$args$.map($addRoot$).join(' '); - return SerializationConstant.DerivedSignal_CHAR + syncFnId + (args.length ? ' ' + args : ''); + return syncFnId + (args.length ? ' ' + args : ''); } -function deserializeDerivedSignal2( - getObjectById: (id: number | string) => any, - rest: string, -): EffectSubscriptions { - const [effectId, prop] = rest.split(' '); - const effect = getObjectById(effectId); - console.log('DESERIALIZE', effectId, prop) - return [[effect, prop]]; +function deserializeSignal2( + signal: Signal2, + container: DomContainer, + data: string, + readFn: boolean, + readQrl: boolean, +) { + signal.$container$ = container; + const parts = data.substring(1).split(';'); + let idx = 0; + console.log('DESERIALIZE', parts); + if (readFn) { + const derivedSignal = signal as DerivedSignal2 + derivedSignal.$invalid$ = false; + const fnParts = parts[idx++].split(' '); + derivedSignal.$func$ = container.getSyncFn(parseInt(fnParts[0])); + for (let i = 1; i < fnParts.length; i++) { + (derivedSignal.$args$ || (derivedSignal.$args$ = [])) + .push(container.$getObjectById$(parseInt(fnParts[i]))); + } + } + if (readQrl) { + const computedSignal = signal as ComputedSignal2; + computedSignal.$computeQrl$ = parseQRL(parts[idx++]) as fixMeAny; + } + signal.$untrackedValue$ = container.$getObjectById$(parts[idx++]); + while (idx < parts.length) { + // idx == 1 is the attribute name + const effect = parts[idx++].split(' ').map((obj, idx) => idx == 1 ? obj : container.$getObjectById$(obj)); + (signal.$effects$ || (signal.$effects$ = [])).push(effect as fixMeAny); + } + console.log(signal.toString()) } function setSerializableDataRootId($addRoot$: (value: any) => number, obj: object, value: any) { diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index c729be92b1f..1b73280dc04 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -107,18 +107,22 @@ export type Effect = Task | VNode | Signal2; * * The second position `string|boolean` store the property name of the effect. * - property name of the VNode - * - `true` if component. - * - `false` if VNode update. (not component) + * - `EffectProperty.COMPONENT` if component + * - `EffectProperty.VNODE` if VNode */ export type EffectSubscriptions = [ Effect, // EffectSubscriptionsProp.EFFECT - string | boolean, // EffectSubscriptionsProp.PROPERTY + string, // EffectSubscriptionsProp.PROPERTY ...Signal2[]]; export const enum EffectSubscriptionsProp { EFFECT = 0, PROPERTY = 1, FIRST_BACK_REF = 2, } +export const enum EffectProperty { + COMPONENT = ':', + VNODE = '.' +} export class Signal2 implements ISignal2 { $untrackedValue$: T; @@ -156,7 +160,7 @@ export class Signal2 implements ISignal2 { if (!effectSubscriber && ctx.$hostElement$) { const host: VNode | null = ctx.$hostElement$ as any; if (host) { - effectSubscriber = [host, true /* component */]; + effectSubscriber = [host, EffectProperty.COMPONENT]; } } if (effectSubscriber) { @@ -210,13 +214,13 @@ export class Signal2 implements ISignal2 { } finally { signal = previousSignal; } - } else if (property === true) { + } else if (property === EffectProperty.COMPONENT) { const host: HostElement = effect as any; const qrl = this.$container$.getHostProp any>>(host, OnRenderProp); assertDefined(qrl, 'Component must have QRL'); const props = this.$container$.getHostProp(host, ELEMENT_PROPS); this.$container$.$scheduler$(ChoreType.COMPONENT, host, qrl, props); - } else if (property === false) { + } else if (property === EffectProperty.VNODE) { const host: HostElement = effect as any; const target = host; this.$container$.$scheduler$(ChoreType.NODE_DIFF, host, target, signal as fixMeAny); @@ -321,7 +325,7 @@ export class ComputedSignal2 extends Signal2 { const ctx = tryGetInvokeContext(); assertDefined(computeQrl, 'Signal is marked as dirty, but no compute function is provided.'); const previousEffectSubscription = ctx?.$effectSubscriber$; - ctx && (ctx.$effectSubscriber$ = [this, false]); + ctx && (ctx.$effectSubscriber$ = [this, EffectProperty.VNODE]); assertTrue( !!computeQrl.resolved, 'Computed signals must run sync. Expected the QRL to be resolved at this point.' @@ -404,7 +408,7 @@ export class DerivedSignal2 extends Signal2 { if (!this.$invalid$) { return false; } - this.$untrackedValue$ = trackSignal2(() => this.$func$(...this.$args$), this, false, this.$container$!); + this.$untrackedValue$ = trackSignal2(() => this.$func$(...this.$args$), this, EffectProperty.VNODE, this.$container$!); } // Getters don't get inherited diff --git a/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts index 35b98905665..54cb05e844a 100644 --- a/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts +++ b/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts @@ -39,7 +39,7 @@ import { type SSRStreamChildren, } from '../../render/jsx/utils.public'; import { isAsyncGenerator } from '../../util/async-generator'; -import { DerivedSignal2, isSignal2 } from '../signal/v2-signal'; +import { DerivedSignal2, EffectProperty, isSignal2 } from '../signal/v2-signal'; import { trackSignal2 } from '../../use/use-core'; class SetScopedStyle { @@ -145,7 +145,7 @@ function processJSXNode( // TODO(mhevery): It is unclear to me why we need to serialize host for SignalDerived. // const host = ssr.getComponentFrame(0)!.componentNode as fixMeAny; enqueue(ssr.closeFragment); - enqueue(trackSignal2(() => (value.value as any), signalNode, false, ssr)); + enqueue(trackSignal2(() => (value.value as any), signalNode, EffectProperty.VNODE, ssr)); } else if (isPromise(value)) { ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.Awaited] : EMPTY_ARRAY); enqueue(ssr.closeFragment); @@ -494,7 +494,7 @@ function getSlotName(host: ISsrNode, jsx: JSXNode, ssr: SSRContainer): string { if (constProps && typeof constProps == 'object' && 'name' in constProps) { const constValue = constProps.name; if (constValue instanceof DerivedSignal2) { - return trackSignal2(() => constValue.value, host as fixMeAny, false, ssr); + return trackSignal2(() => constValue.value, host as fixMeAny, EffectProperty.VNODE, ssr); } } return (jsx.props.name as string) || QDefaultSlot; From 23a0a667b070eece597ecb8529cf0b6054cc9d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Fri, 12 Jul 2024 17:04:22 +0200 Subject: [PATCH 18/89] WIP: use-signal.spec.tsx passing --- packages/qwik/src/core/index.ts | 2 +- packages/qwik/src/core/use/use-core.ts | 1 - .../src/core/v2/shared/shared-container.ts | 20 ++--- .../core/v2/shared/shared-serialization.ts | 3 - .../src/core/v2/signal/v2-signal.public.ts | 3 + packages/qwik/src/core/v2/signal/v2-signal.ts | 2 +- .../qwik/src/core/v2/ssr/ssr-render-jsx.ts | 23 +++--- .../src/core/v2/tests/projection.spec.tsx | 76 +++++++++---------- .../src/core/v2/tests/use-signal.spec.tsx | 6 +- packages/qwik/src/server/v2-ssr-container.ts | 9 +-- 10 files changed, 68 insertions(+), 77 deletions(-) diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index d1ca333deed..6f581590f3f 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -133,7 +133,7 @@ export type { ValueOrPromise } from './util/types'; export type { Signal, ReadonlySignal } from './state/signal'; export { type NoSerialize, SubscriptionType } from './state/common'; export { noSerialize } from './state/common'; -export { isSignal } from './state/signal'; +export { isSignal } from './v2/signal/v2-signal.public'; export { version } from './version'; ////////////////////////////////////////////////////////////////////////////////////////// diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index 5501067ebce..bfd185234c4 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -266,7 +266,6 @@ export const trackSignal = (signal: Signal, sub: Subscriber): T => { * @returns */ export const trackSignal2 = (fn: () => T, subscriber: Effect, property: string, container: Container2): T => { - console.log(">>>>>>>>>> PROPERTY", property); const previousSubscriber = trackInvocation.$effectSubscriber$; const previousContainer = trackInvocation.$container2$; try { diff --git a/packages/qwik/src/core/v2/shared/shared-container.ts b/packages/qwik/src/core/v2/shared/shared-container.ts index b2f9696d0e6..657134f268d 100644 --- a/packages/qwik/src/core/v2/shared/shared-container.ts +++ b/packages/qwik/src/core/v2/shared/shared-container.ts @@ -2,19 +2,19 @@ import type { ObjToProxyMap } from '../../container/container'; import type { JSXOutput } from '../../render/jsx/types/jsx-node'; import { createSubscriptionManager, - type Subscriber, - type SubscriptionManager, + type SubscriptionManager } from '../../state/common'; +import type { Signal } from '../../state/signal'; import type { ContextId } from '../../use/use-context'; +import { trackSignal2 } from '../../use/use-core'; import type { ValueOrPromise } from '../../util/types'; +import { version } from '../../version'; +import type { Effect } from '../signal/v2-signal'; +import type { StreamWriter, SymbolToChunkResolver } from '../ssr/ssr-types'; import type { Scheduler } from './scheduler'; -import { createSerializationContext, type SerializationContext } from './shared-serialization'; -import type { Container2, fixMeAny, HostElement } from './types'; import { createScheduler } from './scheduler'; -import type { StreamWriter, SymbolToChunkResolver } from '../ssr/ssr-types'; -import { version } from '../../version'; -import { trackSignal } from '../../use/use-core'; -import type { Signal } from '../../state/signal'; +import { createSerializationContext, type SerializationContext } from './shared-serialization'; +import type { Container2, HostElement, fixMeAny } from './types'; /** @internal */ export abstract class _SharedContainer implements Container2 { @@ -47,8 +47,8 @@ export abstract class _SharedContainer implements Container2 { this.$scheduler$ = createScheduler(this, scheduleDrain, journalFlush); } - trackSignalValue(signal: Signal, sub: Subscriber): T { - return trackSignal(signal, sub); + trackSignalValue(signal: Signal, subscriber: Effect, property: string): T { + return trackSignal2(() => signal.value, subscriber, property, this); } serializationCtxFactory( diff --git a/packages/qwik/src/core/v2/shared/shared-serialization.ts b/packages/qwik/src/core/v2/shared/shared-serialization.ts index ea943d26e9f..c59448aafc9 100644 --- a/packages/qwik/src/core/v2/shared/shared-serialization.ts +++ b/packages/qwik/src/core/v2/shared/shared-serialization.ts @@ -838,7 +838,6 @@ function serialize(serializationContext: SerializationContext): void { $addRoot$ ); } else if (value instanceof Signal2) { - console.log('SERIALIZE', String(value)); if (value instanceof DerivedSignal2) { writeString( SerializationConstant.DerivedSignal_CHAR + @@ -1069,7 +1068,6 @@ function deserializeSignal2( signal.$container$ = container; const parts = data.substring(1).split(';'); let idx = 0; - console.log('DESERIALIZE', parts); if (readFn) { const derivedSignal = signal as DerivedSignal2 derivedSignal.$invalid$ = false; @@ -1090,7 +1088,6 @@ function deserializeSignal2( const effect = parts[idx++].split(' ').map((obj, idx) => idx == 1 ? obj : container.$getObjectById$(obj)); (signal.$effects$ || (signal.$effects$ = [])).push(effect as fixMeAny); } - console.log(signal.toString()) } function setSerializableDataRootId($addRoot$: (value: any) => number, obj: object, value: any) { diff --git a/packages/qwik/src/core/v2/signal/v2-signal.public.ts b/packages/qwik/src/core/v2/signal/v2-signal.public.ts index 4b266bf6065..f21f4b7736e 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.public.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.public.ts @@ -5,6 +5,8 @@ import { createComputedSignal2 as _createComputedSignal2, } from './v2-signal'; +export { isSignal2 as isSignal } from './v2-signal'; + export interface ReadonlySignal2 { readonly untrackedValue: T; readonly value: T; @@ -23,6 +25,7 @@ export interface ComputedSignal2 extends ReadonlySignal2 { force(): void; } + export const createSignal2: { (): Signal2; (value: T): Signal2; diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index 1b73280dc04..ad8924c0528 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -29,7 +29,7 @@ import { ChoreType } from '../shared/scheduler'; import type { Container2, HostElement, fixMeAny } from '../shared/types'; import type { Signal2 as ISignal2 } from './v2-signal.public'; -const DEBUG = true; +const DEBUG = false; /** * Special value used to mark that a given signal needs to be computed. This is essentially a diff --git a/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts index 54cb05e844a..ae694b1d0c8 100644 --- a/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts +++ b/packages/qwik/src/core/v2/ssr/ssr-render-jsx.ts @@ -7,17 +7,24 @@ import { Fragment } from '../../render/jsx/jsx-runtime'; import { Slot } from '../../render/jsx/slot.public'; import type { JSXNode, JSXOutput } from '../../render/jsx/types/jsx-node'; import type { JSXChildren } from '../../render/jsx/types/jsx-qwik-attributes'; -import { SubscriptionType } from '../../state/common'; +import { + SSRComment, + SSRRaw, + SSRStream, + type SSRStreamChildren, +} from '../../render/jsx/utils.public'; +import { trackSignal2 } from '../../use/use-core'; +import { isAsyncGenerator } from '../../util/async-generator'; import { EMPTY_ARRAY } from '../../util/flyweight'; import { throwErrorAndStop } from '../../util/log'; import { ELEMENT_KEY, FLUSH_COMMENT, - QDefaultSlot, QContainerAttr, + QContainerAttrEnd, + QDefaultSlot, QScopedStyle, QSlot, - QContainerAttrEnd, } from '../../util/markers'; import { isPromise } from '../../util/promises'; import { isFunction, type ValueOrPromise } from '../../util/types'; @@ -30,17 +37,9 @@ import { import { addComponentStylePrefix, hasClassAttr, isClassAttr } from '../shared/scoped-styles'; import { qrlToString, type SerializationContext } from '../shared/shared-serialization'; import { DEBUG_TYPE, QContainerValue, VirtualType, type fixMeAny } from '../shared/types'; +import { DerivedSignal2, EffectProperty, isSignal2 } from '../signal/v2-signal'; import { applyInlineComponent, applyQwikComponentBody } from './ssr-render-component'; import type { ISsrNode, SSRContainer, SsrAttrs } from './ssr-types'; -import { - SSRComment, - SSRRaw, - SSRStream, - type SSRStreamChildren, -} from '../../render/jsx/utils.public'; -import { isAsyncGenerator } from '../../util/async-generator'; -import { DerivedSignal2, EffectProperty, isSignal2 } from '../signal/v2-signal'; -import { trackSignal2 } from '../../use/use-core'; class SetScopedStyle { constructor(public $scopedStyle$: string | null) {} diff --git a/packages/qwik/src/core/v2/tests/projection.spec.tsx b/packages/qwik/src/core/v2/tests/projection.spec.tsx index 7e581a332a8..43b573842a5 100644 --- a/packages/qwik/src/core/v2/tests/projection.spec.tsx +++ b/packages/qwik/src/core/v2/tests/projection.spec.tsx @@ -22,7 +22,7 @@ import { import { vnode_getNextSibling } from '../client/vnode'; import { HTML_NS, SVG_NS } from '../../util/markers'; -const debug = true; +const DEBUG = false; /** * Below are helper components that are constant. They have to be in the top level scope so that the @@ -56,7 +56,7 @@ describe.each([ ); }); - const { vNode } = await render(render-content, { debug }); + const { vNode } = await render(render-content, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -74,7 +74,7 @@ describe.each([ const Parent = component$(() => { return parent-content; }); - const { vNode } = await render(render-content, { debug }); + const { vNode } = await render(render-content, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -98,7 +98,7 @@ describe.each([ const Parent = component$(() => { return ; }); - const { vNode } = await render(, { debug }); + const { vNode } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -114,7 +114,7 @@ describe.each([ const Parent = component$(() => { return projection-value; }); - const { vNode } = await render(, { debug }); + const { vNode } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -140,7 +140,7 @@ describe.each([ ); }); - const { vNode } = await render(second 3, { debug }); + const { vNode } = await render(second 3, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -180,7 +180,7 @@ describe.each([ parent , - { debug } + { debug: DEBUG } ); expect(vNode).toMatchVDOM( @@ -215,7 +215,7 @@ describe.each([ ); }); - const { vNode } = await render(, { debug }); + const { vNode } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -238,7 +238,7 @@ describe.each([ const Parent = component$(() => { return parent-content; }); - const { vNode, container } = await render(render-content, { debug }); + const { vNode, container } = await render(render-content, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -265,7 +265,7 @@ describe.each([ ); }; - const { vNode } = await render(render-content, { debug }); + const { vNode } = await render(render-content, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -281,7 +281,7 @@ describe.each([ const Parent = component$(() => { return child-content; }); - const { vNode } = await render(parent-content, { debug }); + const { vNode } = await render(parent-content, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -323,7 +323,7 @@ describe.each([ ); }); const log = (globalThis as any).log; - const { document } = await render(, { debug }); + const { document } = await render(, { debug: DEBUG }); const isSsr = render === ssrRenderToDom; expect(log).toEqual(isSsr ? ['task', 'cleanup'] : ['task']); log.length = 0; @@ -366,7 +366,7 @@ describe.each([ ); }); - const { vNode, document } = await render(, { debug }); + const { vNode, document } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM(
@@ -470,7 +470,7 @@ describe.each([ ); }); - const { vNode, document } = await render(, { debug }); + const { vNode, document } = await render(, { debug: DEBUG }); await trigger(document.body, 'button', 'click'); await trigger(document.body, 'button', 'click'); @@ -538,7 +538,7 @@ describe.each([ ); }); - const { vNode, document } = await render(, { debug }); + const { vNode, document } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -612,7 +612,7 @@ describe.each([ ); }); - const { vNode } = await render(, { debug }); + const { vNode } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -654,7 +654,7 @@ describe.each([ ); }); - const { vNode } = await render(, { debug }); + const { vNode } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -709,7 +709,7 @@ describe.each([ }); it('should work when parent removes content', async () => { const { vNode, document } = await render(, { - debug, + debug: DEBUG, }); expect(vNode).toMatchVDOM( @@ -743,7 +743,7 @@ describe.each([ }); it('should work when child removes projection', async () => { const { vNode, document } = await render(, { - debug, + debug: DEBUG, }); expect(vNode).toMatchVDOM( @@ -845,7 +845,7 @@ describe.each([ }); it('should work when parent adds content', async () => { const { vNode, document } = await render(, { - debug, + debug: DEBUG, }); expect(vNode).toMatchVDOM( @@ -879,7 +879,7 @@ describe.each([ }); it('should work when child adds projection', async () => { const { vNode, document } = await render(, { - debug, + debug: DEBUG, }); expect(vNode).toMatchVDOM( @@ -930,7 +930,7 @@ describe.each([ ); }); - const { document } = await render(, { debug }); + const { document } = await render(, { debug: DEBUG }); await expect(document.querySelector('#first')).toMatchDOM(
A variable here! @@ -958,7 +958,7 @@ describe.each([ const content = Some content; - const { document, vNode } = await render({content}, { debug }); + const { document, vNode } = await render({content}, { debug: DEBUG }); if (render == ssrRenderToDom) { await expect(document.querySelector('q\\:template')).toMatchDOM( {content} @@ -1017,7 +1017,7 @@ describe.each([ const content = Some content; - const { document } = await render({content}, { debug }); + const { document } = await render({content}, { debug: DEBUG }); expect(document.querySelector('q\\:template')).toBeUndefined(); }); @@ -1046,7 +1046,7 @@ describe.each([ ); }); - const { document } = await render(, { debug }); + const { document } = await render(, { debug: DEBUG }); if (render == ssrRenderToDom) { await expect(document.querySelector('q\\:template')).toMatchDOM( {content} @@ -1102,7 +1102,7 @@ describe.each([ ); }); - const { document } = await render(, { debug }); + const { document } = await render(, { debug: DEBUG }); if (render == ssrRenderToDom) { await expect(document.querySelector('q\\:template')).toMatchDOM( {content} @@ -1138,7 +1138,7 @@ describe.each([ ); }); - const { document, vNode } = await render(, { debug }); + const { document, vNode } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -1208,7 +1208,7 @@ describe.each([ ); }); - const { container, document, vNode } = await render(, { debug }); + const { container, document, vNode } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -1303,7 +1303,7 @@ describe.each([ ); }); - const { container, document, vNode } = await render(, { debug }); + const { container, document, vNode } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -1383,7 +1383,7 @@ describe.each([ ); }); - const { container, document, vNode } = await render(, { debug }); + const { container, document, vNode } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -1472,7 +1472,7 @@ describe.each([ , - { debug } + { debug: DEBUG } ); expect(vNode).toMatchVDOM( @@ -1555,7 +1555,7 @@ describe.each([

DYNAMIC , - { debug } + { debug: DEBUG } ); expect(removeKeyAttrs(document.querySelector('div')?.innerHTML || '')).toContain( '

CHILDDYNAMIC' @@ -1623,7 +1623,7 @@ describe.each([

DYNAMIC , - { debug } + { debug: DEBUG } ); await expect(document.querySelector('div')).toMatchDOM(
@@ -1681,7 +1681,7 @@ describe.each([
, - { debug } + { debug: DEBUG } ); expect(vNode).toMatchVDOM(
@@ -1766,7 +1766,7 @@ describe.each([ ); }); - const { vNode, document } = await render(, { debug }); + const { vNode, document } = await render(, { debug: DEBUG }); await trigger(document.body, '#flip', 'click'); await trigger(document.body, '#counter', 'click'); expect(vNode).toMatchVDOM( @@ -1865,7 +1865,7 @@ describe.each([ return cmp; }); - const { vNode, document } = await render(, { debug }); + const { vNode, document } = await render(, { debug: DEBUG }); expect(vNode).toMatchVDOM( @@ -1986,7 +1986,7 @@ describe.each([

index page

, - { debug } + { debug: DEBUG } ); if (render === ssrRenderToDom) { await trigger(document.body, 'div', ':document:qinit'); @@ -2059,7 +2059,7 @@ describe.each([ ); }); - const { vNode, document } = await render(, { debug }); + const { vNode, document } = await render(, { debug: DEBUG }); if (render === ssrRenderToDom) { await trigger(document.body, 'div', ':document:qinit'); } diff --git a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx index 7f6897433d2..e917f4a90d4 100644 --- a/packages/qwik/src/core/v2/tests/use-signal.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-signal.spec.tsx @@ -15,14 +15,14 @@ import type { Signal as SignalType } from '../../state/signal'; import { untrack } from '../../use/use-core'; import { useSignal } from '../../use/use-signal'; -const debug = true; //true; +const debug = false; //true; Error.stackTraceLimit = 100; describe.each([ { render: ssrRenderToDom }, // - // { render: domRender }, // + { render: domRender }, // ])('$render.name: useSignal', ({ render }) => { - it.only('should update value', async () => { + it('should update value', async () => { const Counter = component$((props: { initial: number }) => { const count = useSignal(props.initial); return ; diff --git a/packages/qwik/src/server/v2-ssr-container.ts b/packages/qwik/src/server/v2-ssr-container.ts index c7c09cd4997..d9c6120e3ec 100644 --- a/packages/qwik/src/server/v2-ssr-container.ts +++ b/packages/qwik/src/server/v2-ssr-container.ts @@ -1073,14 +1073,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { value.value = lastNode; continue; } else { - value = this.trackSignalValue(value, [ - immutable ? SubscriptionType.PROP_IMMUTABLE : SubscriptionType.PROP_MUTABLE, - lastNode as fixMeAny, - value, - lastNode as fixMeAny, - key, - styleScopedId || undefined, - ]); + value = this.trackSignalValue(value, lastNode as fixMeAny, key); } } From 030b4fa160384758ab74f8a2d7bdbfe69be10f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Tue, 23 Jul 2024 06:15:10 -0700 Subject: [PATCH 19/89] WIP: signals should work started work on stores --- packages/qwik/src/core/state/common.ts | 6 +- packages/qwik/src/core/state/signal.ts | 12 +- .../qwik/src/core/use/use-store.public.ts | 5 +- .../core/v2/shared/shared-serialization.ts | 44 +++++- packages/qwik/src/core/v2/signal/v2-signal.ts | 126 +++++++++------- packages/qwik/src/core/v2/signal/v2-store.ts | 140 ++++++++++++++++++ .../qwik/src/core/v2/signal/v2-store.unit.tsx | 18 +++ .../qwik/src/core/v2/tests/use-store.spec.tsx | 6 +- packages/qwik/src/server/v2-ssr-container.ts | 9 +- 9 files changed, 288 insertions(+), 78 deletions(-) create mode 100644 packages/qwik/src/core/v2/signal/v2-store.ts create mode 100644 packages/qwik/src/core/v2/signal/v2-store.unit.tsx diff --git a/packages/qwik/src/core/state/common.ts b/packages/qwik/src/core/state/common.ts index 8a3ee975b6c..6123e72f25c 100644 --- a/packages/qwik/src/core/state/common.ts +++ b/packages/qwik/src/core/state/common.ts @@ -27,8 +27,10 @@ import type { DomContainer } from '../v2/client/dom-container'; import { ElementVNodeProps, type VNode, type VirtualVNode } from '../v2/client/types'; import { VNodeJournalOpCode, vnode_setAttr } from '../v2/client/vnode'; import { ChoreType } from '../v2/shared/scheduler'; +import { canSerialize2 } from '../v2/shared/shared-serialization'; import { isContainer2, type fixMeAny } from '../v2/shared/types'; import { isSignal2 } from '../v2/signal/v2-signal'; +import { unwrapStore2 } from '../v2/signal/v2-store'; import { QObjectFlagsSymbol, QObjectManagerSymbol, QObjectTargetSymbol } from './constants'; import { tryGetContext } from './context'; import type { Signal } from './signal'; @@ -58,7 +60,7 @@ export const verifySerializable = (value: T, preMessage?: string): T => { }; const _verifySerializable = (value: T, seen: Set, ctx: string, preMessage?: string): T => { - const unwrapped = unwrapProxy(value); + const unwrapped = unwrapStore2(value); if (unwrapped == null) { return value; } @@ -70,7 +72,7 @@ const _verifySerializable = (value: T, seen: Set, ctx: string, preMessag if (isSignal2(unwrapped)) { return value; } - if (canSerialize(unwrapped)) { + if (canSerialize2(unwrapped)) { return value; } const typeObj = typeof unwrapped; diff --git a/packages/qwik/src/core/state/signal.ts b/packages/qwik/src/core/state/signal.ts index c1bb67deb48..40cb475b289 100644 --- a/packages/qwik/src/core/state/signal.ts +++ b/packages/qwik/src/core/state/signal.ts @@ -5,6 +5,7 @@ import { ComputedEvent, RenderEvent, ResourceEvent } from '../util/markers'; import { qDev, qSerialize } from '../util/qdev'; import { isObject } from '../util/types'; import { DerivedSignal2, isSignal2 } from '../v2/signal/v2-signal'; +import { getStoreTarget2 } from '../v2/signal/v2-store'; import { LocalSubscriptionManager, getProxyTarget, @@ -208,10 +209,6 @@ export const _wrapProp = , P extends keyof T>(obj: T, if (!isObject(obj)) { return obj[prop]; } - if (isSignal(obj)) { - assertEqual(prop, 'value', 'Left side is a signal, prop must be value'); - return new SignalDerived(getProp, [obj, prop as string]); - } if (isSignal2(obj)) { assertEqual(prop, 'value', 'Left side is a signal, prop must be value'); return new DerivedSignal2(null, getProp, [obj, prop as string], null); @@ -223,12 +220,13 @@ export const _wrapProp = , P extends keyof T>(obj: T, return constProps[prop]; } } else { - const target = getProxyTarget(obj); + const target = getStoreTarget2(obj); if (target) { const signal = target[prop]; - return isSignal(signal) ? signal : new SignalDerived(getProp, [obj, prop as string]); + const wrappedValue = isSignal2(signal) ? signal : new DerivedSignal2(null, getProp, [obj, prop as string], null); + return wrappedValue } } // We need to forward the access to the original object - return new SignalDerived(getProp, [obj, prop as string]); + return new DerivedSignal2(null, getProp, [obj, prop as string], null); }; diff --git a/packages/qwik/src/core/use/use-store.public.ts b/packages/qwik/src/core/use/use-store.public.ts index 8186d73c4cf..b6ad96f75b8 100644 --- a/packages/qwik/src/core/use/use-store.public.ts +++ b/packages/qwik/src/core/use/use-store.public.ts @@ -1,6 +1,7 @@ import { QObjectRecursive } from '../state/constants'; import { getOrCreateProxy } from '../state/store'; import { isFunction } from '../util/types'; +import { Store2Flags, getOrCreateStore2 } from '../v2/signal/v2-store'; import { invoke } from './use-core'; import { useSequentialScope } from './use-sequential-scope'; @@ -91,8 +92,8 @@ export const useStore = ( } else { const containerState = iCtx.$container2$ || iCtx.$renderCtx$.$static$.$containerState$; const recursive = opts?.deep ?? true; - const flags = recursive ? QObjectRecursive : 0; - const newStore = getOrCreateProxy(value, containerState, flags); + const flags = recursive ? Store2Flags.RECURSIVE : Store2Flags.NONE; + const newStore = getOrCreateStore2(value, flags, containerState); set(newStore); return newStore; } diff --git a/packages/qwik/src/core/v2/shared/shared-serialization.ts b/packages/qwik/src/core/v2/shared/shared-serialization.ts index c59448aafc9..d89e91cb22d 100644 --- a/packages/qwik/src/core/v2/shared/shared-serialization.ts +++ b/packages/qwik/src/core/v2/shared/shared-serialization.ts @@ -22,24 +22,20 @@ import { } from '../../render/jsx/jsx-runtime'; import { Slot } from '../../render/jsx/slot.public'; import { - SubscriptionProp, fastSkipSerialize, getProxyFlags, getSubscriptionManager, - unwrapProxy, - type LocalSubscriptionManager, - type Subscriber, + unwrapProxy } from '../../state/common'; import { _CONST_PROPS, _VAR_PROPS } from '../../state/constants'; import { getOrCreateProxy, isStore } from '../../state/store'; import { Task, type ResourceReturnInternal } from '../../use/use-task'; import { throwErrorAndStop } from '../../util/log'; +import { ELEMENT_ID } from '../../util/markers'; import { isPromise } from '../../util/promises'; import type { ValueOrPromise } from '../../util/types'; import type { DomContainer } from '../client/dom-container'; -import { vnode_getNode, vnode_isVNode, vnode_locate } from '../client/vnode'; -import type { SymbolToChunkResolver } from '../ssr/ssr-types'; -import { ELEMENT_ID } from '../../util/markers'; +import { vnode_isVNode, vnode_locate } from '../client/vnode'; import { ComputedSignal2, DerivedSignal2, @@ -47,7 +43,9 @@ import { Signal2, type EffectSubscriptions, } from '../signal/v2-signal'; +import type { SymbolToChunkResolver } from '../ssr/ssr-types'; import type { fixMeAny } from './types'; +import { Store2, unwrapStore2 } from '../signal/v2-store'; const deserializedProxyMap = new WeakMap(); @@ -1200,6 +1198,38 @@ const frameworkType = (obj: any) => { ); }; +export const canSerialize2 = (value: any): boolean => { + if (value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return true; + } else if (typeof value === 'object') { + let proto = Object.getPrototypeOf(value); + if (proto === Store2.prototype) { + value = unwrapStore2(value); + proto = Object.prototype; + } + if (proto == Object.prototype) { + for (const key in value) { + if (!canSerialize2(value[key])) { + return false; + } + } + return true; + } else if (proto == Array.prototype) { + for (let i = 0; i < value.length; i++) { + if (!canSerialize2(value[i])) { + return false; + } + } + return true; + } + } else if (typeof value === 'function') { + if (isQrl(value) || isQwikComponent(value)) { + return true; + } + } + return false; +} + const QRL_RUNTIME_CHUNK = 'qwik-runtime-mock-chunk'; const SERIALIZABLE_ROOT_ID = Symbol('SERIALIZABLE_ROOT_ID'); diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index ad8924c0528..af68ea4727a 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -28,6 +28,7 @@ import type { VNode } from '../client/types'; import { ChoreType } from '../shared/scheduler'; import type { Container2, HostElement, fixMeAny } from '../shared/types'; import type { Signal2 as ISignal2 } from './v2-signal.public'; +import type { Store2 } from './v2-store'; const DEBUG = false; @@ -113,7 +114,11 @@ export type Effect = Task | VNode | Signal2; export type EffectSubscriptions = [ Effect, // EffectSubscriptionsProp.EFFECT string, // EffectSubscriptionsProp.PROPERTY - ...Signal2[]]; + ...( // NOTE even thought this is shown as `...(string|Signal2)` + // it is a list of strings followed by a list of signals (not intermingled) + string | // List of properties (Only used with Store2 (not with Signal2)) + Signal2 | Store2 // List of signals to release + )[]]; export const enum EffectSubscriptionsProp { EFFECT = 0, PROPERTY = 1, @@ -128,7 +133,6 @@ export class Signal2 implements ISignal2 { $untrackedValue$: T; /** Store a list of effects which are dependent on this signal. */ - // TODO perf: use a set? $effects$: null | EffectSubscriptions[] = null; $container$: Container2 | null = null; @@ -168,7 +172,7 @@ export class Signal2 implements ISignal2 { // Let's make sure that we have a reference to this effect. // Adding reference is essentially adding a subscription, so if the signal // changes we know who to notify. - ensureContains(effects, effectSubscriber); + ensureContainsEffect(effects, effectSubscriber); // But when effect is scheduled in needs to be able to know which signals // to unsubscribe from. So we need to store the reference from the effect back // to this signal. @@ -183,57 +187,10 @@ export class Signal2 implements ISignal2 { if (value !== this.$untrackedValue$) { DEBUG && log('Signal.set', this.$untrackedValue$, '->', value, pad('\n' + this.toString(), " ")); this.$untrackedValue$ = value; - this.$triggerEffects$(); + triggerEffects(this.$container$, this, this.$effects$); } } - protected $triggerEffects$() { - if (this.$effects$) { - let signal: Signal2 = this; - const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { - const effect = effectSubscriptions[EffectSubscriptionsProp.EFFECT]; - const property = effectSubscriptions[EffectSubscriptionsProp.PROPERTY]; - assertDefined(this.$container$, 'Scheduler must be defined.'); - if (isTask(effect)) { - effect.$flags$ |= TaskFlags.DIRTY; - DEBUG && log('schedule.effect.task', pad('\n' + String(effect), ' ')); - // TODO(mhevery): We should check if visible/resource task and scheduled differently. - this.$container$.$scheduler$(ChoreType.TASK, effectSubscriptions as fixMeAny); - } else if (effect instanceof Signal2) { - // we don't schedule ComputedSignal/DerivedSignal directly, instead we invalidate it and - // and schedule the signals effects (recursively) - if (effect instanceof ComputedSignal2) { - // TODO(misko): ensure that the computed signal's QRL is resolved. - // If not resolved scheduled it to be resolved. - } - (effect as ComputedSignal2 | DerivedSignal2).$invalid$ = true; - const previousSignal = signal; - try { - signal = effect; - effect.$effects$?.forEach(scheduleEffect); - } finally { - signal = previousSignal; - } - } else if (property === EffectProperty.COMPONENT) { - const host: HostElement = effect as any; - const qrl = this.$container$.getHostProp any>>(host, OnRenderProp); - assertDefined(qrl, 'Component must have QRL'); - const props = this.$container$.getHostProp(host, ELEMENT_PROPS); - this.$container$.$scheduler$(ChoreType.COMPONENT, host, qrl, props); - } else if (property === EffectProperty.VNODE) { - const host: HostElement = effect as any; - const target = host; - this.$container$.$scheduler$(ChoreType.NODE_DIFF, host, target, signal as fixMeAny); - } else { - const host: HostElement = effect as any; - this.$container$.$scheduler$(ChoreType.NODE_PROP, host, property, signal as fixMeAny); - } - }; - this.$effects$.forEach(scheduleEffect); - } - - DEBUG && log('done scheduling'); - } // prevent accidental use as value valueOf() { @@ -251,13 +208,70 @@ export class Signal2 implements ISignal2 { } /** Ensure the item is in array (do nothing if already there) */ -function ensureContains(array: any[], value: any) { +export const ensureContains = (array: any[], value: any) => { const isMissing = array.indexOf(value) === -1; if (isMissing) { array.push(value); } } +export const ensureContainsEffect = (array: EffectSubscriptions[], effect: EffectSubscriptions) => { + for (let i = 0; i < array.length; i++) { + const existingEffect = array[i]; + if (existingEffect[0] === effect[0] && existingEffect[1] === effect[1]) { + return; + } + } + array.push(effect); +} + +export const triggerEffects = (container: Container2 | null, signal: Signal2 | Store2, effects: EffectSubscriptions[] | null) => { + if (effects) { + const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { + const effect = effectSubscriptions[EffectSubscriptionsProp.EFFECT]; + const property = effectSubscriptions[EffectSubscriptionsProp.PROPERTY]; + assertDefined(container, 'Scheduler must be defined.'); + if (isTask(effect)) { + effect.$flags$ |= TaskFlags.DIRTY; + DEBUG && log('schedule.effect.task', pad('\n' + String(effect), ' ')); + // TODO(mhevery): We should check if visible/resource task and scheduled differently. + container.$scheduler$(ChoreType.TASK, effectSubscriptions as fixMeAny); + } else if (effect instanceof Signal2) { + // we don't schedule ComputedSignal/DerivedSignal directly, instead we invalidate it and + // and schedule the signals effects (recursively) + if (effect instanceof ComputedSignal2) { + // TODO(misko): ensure that the computed signal's QRL is resolved. + // If not resolved scheduled it to be resolved. + } + (effect as ComputedSignal2 | DerivedSignal2).$invalid$ = true; + const previousSignal = signal; + try { + signal = effect; + effect.$effects$?.forEach(scheduleEffect); + } finally { + signal = previousSignal; + } + } else if (property === EffectProperty.COMPONENT) { + const host: HostElement = effect as any; + const qrl = container.getHostProp any>>(host, OnRenderProp); + assertDefined(qrl, 'Component must have QRL'); + const props = container.getHostProp(host, ELEMENT_PROPS); + container.$scheduler$(ChoreType.COMPONENT, host, qrl, props); + } else if (property === EffectProperty.VNODE) { + const host: HostElement = effect as any; + const target = host; + container.$scheduler$(ChoreType.NODE_DIFF, host, target, signal as fixMeAny); + } else { + const host: HostElement = effect as any; + container.$scheduler$(ChoreType.NODE_PROP, host, property, signal as fixMeAny); + } + }; + effects.forEach(scheduleEffect); + } + + DEBUG && log('done scheduling'); +} + /** * A signal which is computed from other signals. @@ -293,7 +307,7 @@ export class ComputedSignal2 extends Signal2 { // Therefore, we need to calculate the value now. // TODO move this calculation to the beginning of the next tick, add chores to that tick if necessary. New chore type? if (this.$computeIfNeeded$()) { - this.$triggerEffects$(); + triggerEffects(this.$container$, this, this.$effects$); } } @@ -303,7 +317,7 @@ export class ComputedSignal2 extends Signal2 { */ force() { this.$invalid$ = true; - this.$triggerEffects$(); + triggerEffects(this.$container$, this, this.$effects$); } get untrackedValue() { @@ -381,7 +395,7 @@ export class DerivedSignal2 extends Signal2 { // Therefore, we need to calculate the value now. // TODO move this calculation to the beginning of the next tick, add chores to that tick if necessary. New chore type? if (this.$computeIfNeeded$()) { - this.$triggerEffects$(); + triggerEffects(this.$container$, this, this.$effects$); } } @@ -391,7 +405,7 @@ export class DerivedSignal2 extends Signal2 { */ force() { this.$invalid$ = true; - this.$triggerEffects$(); + triggerEffects(this.$container$, this, this.$effects$); } get untrackedValue() { diff --git a/packages/qwik/src/core/v2/signal/v2-store.ts b/packages/qwik/src/core/v2/signal/v2-store.ts new file mode 100644 index 00000000000..96cd7828990 --- /dev/null +++ b/packages/qwik/src/core/v2/signal/v2-store.ts @@ -0,0 +1,140 @@ +import { pad, qwikDebugToString } from "../../debug"; +import { assertDefined, assertTrue } from "../../error/assert"; +import { tryGetInvokeContext } from "../../use/use-core"; +import type { VNode } from "../client/types"; +import type { Container2, fixMeAny } from "../shared/types"; +import { EffectProperty, ensureContains, ensureContainsEffect, triggerEffects, type EffectSubscriptions } from "./v2-signal"; + +const DEBUG = false; + +// eslint-disable-next-line no-console +const log = (...args: any[]) => console.log('STORE', ...(args).map(qwikDebugToString)); + + +const storeWeakMap = new WeakMap>(); + +const STORE = Symbol('store'); + +export const enum Store2Flags { + NONE = 0, + RECURSIVE = 1, + IMMUTABLE = 2, +} + +export type Store2 = T & { + __BRAND__: 'Store' +}; + +let _lastTarget: undefined | StoreHandler; + +export const getStoreTarget2 = (value: T): T | null => { + _lastTarget = undefined as any; + return typeof value === 'object' && value && (STORE in value) // this implicitly sets the `_lastTarget` as a side effect. + ? _lastTarget!.$target$ as T : null; +} + +export const unwrapStore2 = (value: T): T => { + return getStoreTarget2(value as fixMeAny) as T || value; +} + +export const isStore2 = (value: T): value is Store2 => { + return value instanceof Store; +} + +export const getOrCreateStore2 = (obj: T, flags: Store2Flags, container?: Container2 | null): Store2 => { + let store: Store2 | undefined = storeWeakMap.get(obj) as Store2 | undefined; + if (!store) { + store = new Proxy(new Store(), new StoreHandler(obj, flags, container || null)) as Store2; + storeWeakMap.set(obj, store as any); + } + return store as Store2; +} + +class Store { + toString() { + return '[Store]'; + } +} + +export const Store2 = Store; + +class StoreHandler> implements ProxyHandler { + $effects$: null | EffectSubscriptions[] = null; + constructor(public $target$: T, public $flags$: Store2Flags, public $container$: Container2 | null) { + } + + get(_: T, p: string | symbol) { + const target = this.$target$; + const ctx = tryGetInvokeContext(); + if (ctx) { + if (this.$container$ === null) { + assertDefined(ctx.$container2$, 'container should be in context '); + // Grab the container now we have access to it + this.$container$ = ctx.$container2$; + } else { + assertTrue( + !ctx.$container2$ || ctx.$container2$ === this.$container$, + 'Do not use signals across containers' + ); + } + let effectSubscriber = ctx.$effectSubscriber$; + if (!effectSubscriber && ctx.$hostElement$) { + const host: VNode | null = ctx.$hostElement$ as any; + if (host) { + effectSubscriber = [host, EffectProperty.COMPONENT]; + } + } + if (effectSubscriber) { + const effects = (this.$effects$ ||= []); + // Let's make sure that we have a reference to this effect. + // Adding reference is essentially adding a subscription, so if the signal + // changes we know who to notify. + ensureContainsEffect(effects, effectSubscriber); + // But when effect is scheduled in needs to be able to know which signals + // to unsubscribe from. So we need to store the reference from the effect back + // to this signal. + ensureContains(effectSubscriber, this); + DEBUG && log("read->sub", pad('\n' + this.toString(), " ")) + } + } + let value = target[p]; + if (p === 'toString' && value === Object.prototype.toString) { + return Store.prototype.toString; + } + if (typeof value === 'object' && value !== null) { + value = getOrCreateStore2(value, this.$flags$, this.$container$); + } + return value; + } + + + set(_: T, p: string | symbol, value: any): boolean { + const target = this.$target$; + const oldValue = target[p]; + if (value !== oldValue) { + DEBUG && log('Signal.set', oldValue, '->', value, pad('\n' + this.toString(), " ")); + (target as any)[p] = value; + triggerEffects(this.$container$, this, this.$effects$); + } + return true; + } + + deleteProperty(_: T, prop: string | symbol): boolean { + if (typeof prop != 'string' || !delete this.$target$[prop]) { + return false; + } + return true; + } + + has(_: T, p: string | symbol) { + if (p === STORE) { + _lastTarget = this; + return true; + } + return Object.prototype.hasOwnProperty.call(this.$target$, p); + } + + ownKeys(): ArrayLike { + return Reflect.ownKeys(this.$target$); + } +} \ No newline at end of file diff --git a/packages/qwik/src/core/v2/signal/v2-store.unit.tsx b/packages/qwik/src/core/v2/signal/v2-store.unit.tsx new file mode 100644 index 00000000000..7f9cd651576 --- /dev/null +++ b/packages/qwik/src/core/v2/signal/v2-store.unit.tsx @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { Store2Flags, getOrCreateStore2, isStore2 } from "./v2-store"; + +describe('v2/store', () => { + it('should create and toString', () => { + const store = getOrCreateStore2({ name: 'foo' }, Store2Flags.NONE); + expect(isStore2({})).toEqual(false); + expect(isStore2(store)).toEqual(true); + expect(store.toString()).toEqual('[Store]'); + }); + it('should respond to instanceof', () => { + const target = { name: 'foo' }; + Object.freeze(target); + const store = getOrCreateStore2(target, Store2Flags.NONE); + expect(store instanceof Array).toEqual(false); + expect(target instanceof Object).toEqual(true); + }); +}); \ No newline at end of file diff --git a/packages/qwik/src/core/v2/tests/use-store.spec.tsx b/packages/qwik/src/core/v2/tests/use-store.spec.tsx index 132e157857d..053a7d4bb08 100644 --- a/packages/qwik/src/core/v2/tests/use-store.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-store.spec.tsx @@ -10,11 +10,11 @@ import { useSignal } from '../../use/use-signal'; import { useStore } from '../../use/use-store.public'; import { useTask$ } from '../../use/use-task-dollar'; -const debug = false; //true; +const debug = true; //true; Error.stackTraceLimit = 100; describe.each([ - { render: ssrRenderToDom }, // + // { render: ssrRenderToDom }, // { render: domRender }, // ])('$render.name: useStore', ({ render }) => { it('should render value', async () => { @@ -184,7 +184,7 @@ describe.each([ ); }); - it('should allow signal to deliver value or JSX', async () => { + it.only('should allow signal to deliver value or JSX', async () => { const log: string[] = []; const Counter = component$(() => { const count = useStore({ value: 'initial' }); diff --git a/packages/qwik/src/server/v2-ssr-container.ts b/packages/qwik/src/server/v2-ssr-container.ts index d9c6120e3ec..d09a1b63150 100644 --- a/packages/qwik/src/server/v2-ssr-container.ts +++ b/packages/qwik/src/server/v2-ssr-container.ts @@ -1073,7 +1073,14 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { value.value = lastNode; continue; } else { - value = this.trackSignalValue(value, lastNode as fixMeAny, key); + value = this.trackSignalValue(value, lastNode as fixMeAny, key, [ + immutable ? SubscriptionType.PROP_IMMUTABLE : SubscriptionType.PROP_MUTABLE, + lastNode as fixMeAny, + value, + lastNode as fixMeAny, + key, + styleScopedId || undefined, + ]); } } From a4212d7c6285d19aac080e58d668019cccc807eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Tue, 23 Jul 2024 06:26:18 -0700 Subject: [PATCH 20/89] WIP: update signals --- packages/docs/src/routes/api/qwik/api.json | 18 ++++----- packages/docs/src/routes/api/qwik/index.md | 38 ++++++++----------- packages/qwik/src/core/api.md | 14 ++++--- packages/qwik/src/core/state/common.ts | 1 - packages/qwik/src/core/state/signal.ts | 3 +- packages/qwik/src/core/state/store.ts | 2 +- .../qwik/src/core/use/use-store.public.ts | 2 - packages/qwik/src/core/v2/client/vnode.ts | 5 +-- packages/qwik/src/core/v2/shared/scheduler.ts | 2 +- .../core/v2/shared/shared-serialization.ts | 2 + packages/qwik/src/core/v2/signal/v2-signal.ts | 3 ++ .../src/core/v2/signal/v2-signal.unit.tsx | 4 +- .../qwik/src/core/v2/tests/use-store.spec.tsx | 2 +- packages/qwik/src/server/v2-ssr-container.ts | 4 +- 14 files changed, 48 insertions(+), 52 deletions(-) diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index d23768ccf1f..22ede9ec353 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -763,8 +763,8 @@ } ], "kind": "Function", - "content": "```typescript\nevent$: (first: T) => QRL\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfirst\n\n\n\n\nT\n\n\n\n\n\n
\n**Returns:**\n\n[QRL](#qrl)<T>", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/qrl/qrl.public.ts", + "content": "```typescript\nevent$: (first: T) => import(\"./qrl.public\").QRL\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfirst\n\n\n\n\nT\n\n\n\n\n\n
\n**Returns:**\n\nimport(\"./qrl.public\").[QRL](#qrl)<T>", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/qrl/qrl.public.dollar.ts", "mdFile": "qwik.event_.md" }, { @@ -1130,8 +1130,8 @@ } ], "kind": "Function", - "content": "Checks if a given object is a `Signal`.\n\n\n```typescript\nisSignal: (obj: any) => obj is Signal\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nobj\n\n\n\n\nany\n\n\n\n\nThe object to check if `Signal`.\n\n\n
\n**Returns:**\n\nobj is [Signal](#signal)<T>\n\nBoolean - True if the object is a `Signal`.", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/state/signal.ts", + "content": "```typescript\nisSignal2: (value: any) => value is ISignal2\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nvalue\n\n\n\n\nany\n\n\n\n\n\n
\n**Returns:**\n\nvalue is ISignal2<unknown>", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.ts", "mdFile": "qwik.issignal.md" }, { @@ -2951,7 +2951,7 @@ ], "kind": "Variable", "content": "```typescript\nuseComputed$: Computed\n```", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task-dollar.ts", "mdFile": "qwik.usecomputed_.md" }, { @@ -3244,8 +3244,8 @@ } ], "kind": "Function", - "content": "Reruns the `taskFn` when the observed inputs change.\n\nUse `useTask` to observe changes on a set of inputs, and then re-execute the `taskFn` when those inputs change.\n\nThe `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to rerun.\n\n\n```typescript\nuseTask$: (first: TaskFn, opts?: UseTaskOptions | undefined) => void\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfirst\n\n\n\n\n[TaskFn](#taskfn)\n\n\n\n\n\n
\n\nopts\n\n\n\n\n[UseTaskOptions](#usetaskoptions) \\| undefined\n\n\n\n\n_(Optional)_\n\n\n
\n**Returns:**\n\nvoid", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts", + "content": "Reruns the `taskFn` when the observed inputs change.\n\nUse `useTask` to observe changes on a set of inputs, and then re-execute the `taskFn` when those inputs change.\n\nThe `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to rerun.\n\n\n```typescript\nuseTask$: (first: import(\"./use-task\").TaskFn, opts?: import(\"./use-task\").UseTaskOptions | undefined) => void\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfirst\n\n\n\n\nimport(\"./use-task\").[TaskFn](#taskfn)\n\n\n\n\n\n
\n\nopts\n\n\n\n\nimport(\"./use-task\").[UseTaskOptions](#usetaskoptions) \\| undefined\n\n\n\n\n_(Optional)_\n\n\n
\n**Returns:**\n\nvoid", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task-dollar.ts", "mdFile": "qwik.usetask_.md" }, { @@ -3286,8 +3286,8 @@ } ], "kind": "Function", - "content": "```tsx\nconst Timer = component$(() => {\n const store = useStore({\n count: 0,\n });\n\n useVisibleTask$(() => {\n // Only runs in the client\n const timer = setInterval(() => {\n store.count++;\n }, 500);\n return () => {\n clearInterval(timer);\n };\n });\n\n return
{store.count}
;\n});\n```\n\n\n```typescript\nuseVisibleTask$: (first: TaskFn, opts?: OnVisibleTaskOptions | undefined) => void\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfirst\n\n\n\n\n[TaskFn](#taskfn)\n\n\n\n\n\n
\n\nopts\n\n\n\n\n[OnVisibleTaskOptions](#onvisibletaskoptions) \\| undefined\n\n\n\n\n_(Optional)_\n\n\n
\n**Returns:**\n\nvoid", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts", + "content": "```tsx\nconst Timer = component$(() => {\n const store = useStore({\n count: 0,\n });\n\n useVisibleTask$(() => {\n // Only runs in the client\n const timer = setInterval(() => {\n store.count++;\n }, 500);\n return () => {\n clearInterval(timer);\n };\n });\n\n return
{store.count}
;\n});\n```\n\n\n```typescript\nuseVisibleTask$: (first: import(\"./use-task\").TaskFn, opts?: import(\"./use-task\").OnVisibleTaskOptions | undefined) => void\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfirst\n\n\n\n\nimport(\"./use-task\").[TaskFn](#taskfn)\n\n\n\n\n\n
\n\nopts\n\n\n\n\nimport(\"./use-task\").[OnVisibleTaskOptions](#onvisibletaskoptions) \\| undefined\n\n\n\n\n_(Optional)_\n\n\n
\n**Returns:**\n\nvoid", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task-dollar.ts", "mdFile": "qwik.usevisibletask_.md" }, { diff --git a/packages/docs/src/routes/api/qwik/index.md b/packages/docs/src/routes/api/qwik/index.md index ceee236a88a..e74d7eca504 100644 --- a/packages/docs/src/routes/api/qwik/index.md +++ b/packages/docs/src/routes/api/qwik/index.md @@ -2061,7 +2061,7 @@ any \| undefined ## event$ ```typescript -event$: (first: T) => QRL; +event$: (first: T) => import("./qrl.public").QRL; ```
@@ -2091,9 +2091,9 @@ T
**Returns:** -[QRL](#qrl)<T> +import("./qrl.public").[QRL](#qrl)<T> -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/qrl/qrl.public.ts) +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/qrl/qrl.public.dollar.ts) ## EventHandler @@ -2613,10 +2613,8 @@ export interface IntrinsicElements extends IntrinsicHTMLElements, IntrinsicSVGEl ## isSignal -Checks if a given object is a `Signal`. - ```typescript -isSignal: (obj: any) => obj is Signal +isSignal2: (value: any) => value is ISignal2 ```
@@ -2634,7 +2632,7 @@ Description
-obj +value @@ -2642,17 +2640,13 @@ any -The object to check if `Signal`. -
**Returns:** -obj is [Signal](#signal)<T> +value is ISignal2<unknown> -Boolean - True if the object is a `Signal`. - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/state/signal.ts) +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.ts) ## jsx @@ -10010,7 +10004,7 @@ T useComputed$: Computed; ``` -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts) +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task-dollar.ts) ## useComputedQrl @@ -10995,7 +10989,7 @@ Use `useTask` to observe changes on a set of inputs, and then re-execute the `ta The `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to rerun. ```typescript -useTask$: (first: TaskFn, opts?: UseTaskOptions | undefined) => void +useTask$: (first: import("./use-task").TaskFn, opts?: import("./use-task").UseTaskOptions | undefined) => void ```
@@ -11017,7 +11011,7 @@ first -[TaskFn](#taskfn) +import("./use-task").[TaskFn](#taskfn) @@ -11028,7 +11022,7 @@ opts -[UseTaskOptions](#usetaskoptions) \| undefined +import("./use-task").[UseTaskOptions](#usetaskoptions) \| undefined @@ -11040,7 +11034,7 @@ _(Optional)_ void -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts) +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task-dollar.ts) ## UseTaskOptions @@ -11163,7 +11157,7 @@ const Timer = component$(() => { ``` ```typescript -useVisibleTask$: (first: TaskFn, opts?: OnVisibleTaskOptions | undefined) => void +useVisibleTask$: (first: import("./use-task").TaskFn, opts?: import("./use-task").OnVisibleTaskOptions | undefined) => void ```
@@ -11185,7 +11179,7 @@ first -[TaskFn](#taskfn) +import("./use-task").[TaskFn](#taskfn) @@ -11196,7 +11190,7 @@ opts -[OnVisibleTaskOptions](#onvisibletaskoptions) \| undefined +import("./use-task").[OnVisibleTaskOptions](#onvisibletaskoptions) \| undefined @@ -11208,7 +11202,7 @@ _(Optional)_ void -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts) +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task-dollar.ts) ## useVisibleTaskQrl diff --git a/packages/qwik/src/core/api.md b/packages/qwik/src/core/api.md index 1309634d566..e8df737089e 100644 --- a/packages/qwik/src/core/api.md +++ b/packages/qwik/src/core/api.md @@ -361,10 +361,10 @@ export const eventQrl: (qrl: QRL) => QRL; export interface FieldsetHTMLAttributes extends Attrs<'fieldset', T> { } -// Warning: (ae-forgotten-export) The symbol "SignalDerived" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "DerivedSignal2" needs to be exported by the entry point index.d.ts // // @internal (undocumented) -export const _fnSignal: any>(fn: T, args: Parameters, fnStr?: string) => SignalDerived, Parameters>; +export const _fnSignal: any>(fn: T, args: Parameters, fnStr?: string) => DerivedSignal2; // @public (undocumented) export interface FormHTMLAttributes extends Attrs<'form', T> { @@ -507,8 +507,10 @@ export type IntrinsicSVGElements = { // @internal (undocumented) export const _isJSXNode: (n: unknown) => n is JSXNode; -// @public -export const isSignal: (obj: any) => obj is Signal; +// Warning: (ae-forgotten-export) The symbol "Signal2_2" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export const isSignal: (value: any) => value is Signal2_2; // @internal (undocumented) export function _isStringifiable(value: unknown): value is _Stringifiable; @@ -1087,10 +1089,10 @@ export abstract class _SharedContainer implements Container2 { abstract setContext(host: HostElement, context: ContextId, value: T): void; // (undocumented) abstract setHostProp(host: HostElement, name: string, value: T): void; - // Warning: (ae-forgotten-export) The symbol "Subscriber" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "Effect" needs to be exported by the entry point index.d.ts // // (undocumented) - trackSignalValue(signal: Signal, sub: Subscriber): T; + trackSignalValue(signal: Signal, subscriber: Effect, property: string): T; } // @public (undocumented) diff --git a/packages/qwik/src/core/state/common.ts b/packages/qwik/src/core/state/common.ts index 6123e72f25c..bf5f1a63e24 100644 --- a/packages/qwik/src/core/state/common.ts +++ b/packages/qwik/src/core/state/common.ts @@ -1,6 +1,5 @@ import type { OnRenderFn } from '../component/component.public'; import type { ContainerState, GetObjID, GetObject } from '../container/container'; -import { canSerialize } from '../container/serializers'; import { assertDefined, assertFail, assertTrue } from '../error/assert'; import { QError_verifySerializable, qError } from '../error/error'; import type { QRL } from '../qrl/qrl.public'; diff --git a/packages/qwik/src/core/state/signal.ts b/packages/qwik/src/core/state/signal.ts index 40cb475b289..b33b4b3511b 100644 --- a/packages/qwik/src/core/state/signal.ts +++ b/packages/qwik/src/core/state/signal.ts @@ -8,12 +8,11 @@ import { DerivedSignal2, isSignal2 } from '../v2/signal/v2-signal'; import { getStoreTarget2 } from '../v2/signal/v2-store'; import { LocalSubscriptionManager, - getProxyTarget, getSubscriptionManager, verifySerializable, type Subscriber, type SubscriptionManager, - type Subscriptions, + type Subscriptions } from './common'; import { QObjectManagerSymbol, _CONST_PROPS } from './constants'; diff --git a/packages/qwik/src/core/state/store.ts b/packages/qwik/src/core/state/store.ts index 9784b03f9cd..a4d01aa82ee 100644 --- a/packages/qwik/src/core/state/store.ts +++ b/packages/qwik/src/core/state/store.ts @@ -9,7 +9,6 @@ import { isArray, isObject, isSerializableObject } from '../util/types'; import { SERIALIZER_PROXY_UNWRAP, SerializationConstant, - subscriptionManagerFromString, } from '../v2/shared/shared-serialization'; import { LocalSubscriptionManager, @@ -77,6 +76,7 @@ export const createProxy = ( const getSerializedState = (target: object): string | undefined => { return (target as any)[SerializationConstant.Store_CHAR]; }; + const subscriptionManagerFromString: any = null! const removeSerializedState = (target: object) => { delete (target as any)[SerializationConstant.Store_CHAR]; }; diff --git a/packages/qwik/src/core/use/use-store.public.ts b/packages/qwik/src/core/use/use-store.public.ts index b6ad96f75b8..d2d898aa547 100644 --- a/packages/qwik/src/core/use/use-store.public.ts +++ b/packages/qwik/src/core/use/use-store.public.ts @@ -1,5 +1,3 @@ -import { QObjectRecursive } from '../state/constants'; -import { getOrCreateProxy } from '../state/store'; import { isFunction } from '../util/types'; import { Store2Flags, getOrCreateStore2 } from '../v2/signal/v2-store'; import { invoke } from './use-core'; diff --git a/packages/qwik/src/core/v2/client/vnode.ts b/packages/qwik/src/core/v2/client/vnode.ts index c4d50b12ef7..3d95efdfa16 100644 --- a/packages/qwik/src/core/v2/client/vnode.ts +++ b/packages/qwik/src/core/v2/client/vnode.ts @@ -118,8 +118,8 @@ */ import { isDev } from '@builder.io/qwik/build'; +import { qwikDebugToString } from '../../debug'; import { assertDefined, assertEqual, assertFalse, assertTrue } from '../../error/assert'; -import { isQrl } from '../../qrl/qrl-class'; import { dangerouslySetInnerHTML } from '../../render/execute-component'; import { isText } from '../../util/element'; import { throwErrorAndStop } from '../../util/log'; @@ -142,7 +142,7 @@ import { import { isHtmlElement } from '../../util/types'; import { DEBUG_TYPE, QContainerValue, VirtualType, VirtualTypeName } from '../shared/types'; import { VNodeDataChar } from '../shared/vnode-data-types'; -import { getDomContainer, _getQContainerElement } from './dom-container'; +import { getDomContainer } from './dom-container'; import { ElementVNodeProps, TextVNodeProps, @@ -162,7 +162,6 @@ import { vnode_getDomChildrenWithCorrectNamespacesToInsert, vnode_getElementNamespaceFlags, } from './vnode-namespace'; -import { qwikDebugToString } from '../../debug'; ////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/packages/qwik/src/core/v2/shared/scheduler.ts b/packages/qwik/src/core/v2/shared/scheduler.ts index d260e83f529..805d3d891b2 100644 --- a/packages/qwik/src/core/v2/shared/scheduler.ts +++ b/packages/qwik/src/core/v2/shared/scheduler.ts @@ -336,7 +336,7 @@ export const createScheduler = ( value = value.value as any; } // TODO(mhevery): Fix this hack - const journal = container.$journal$ as fixMeAny; + const journal = (container as fixMeAny).$journal$ as fixMeAny; const property = chore.$idx$ as string; value = serializeAttribute(property, value); vnode_setAttr(journal, virtualNode, property, value); diff --git a/packages/qwik/src/core/v2/shared/shared-serialization.ts b/packages/qwik/src/core/v2/shared/shared-serialization.ts index d89e91cb22d..9330b2c98a0 100644 --- a/packages/qwik/src/core/v2/shared/shared-serialization.ts +++ b/packages/qwik/src/core/v2/shared/shared-serialization.ts @@ -990,6 +990,8 @@ function serializeEffectSubs( return data; } +const subscriptionManagerToString: any = null!; + function serializeProxy( value: any, proxy: any, diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index af68ea4727a..a321fefe52f 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -60,6 +60,9 @@ export const createComputedSignal2 = (qrl: QRL<() => T>) => { return new ComputedSignal2(null, qrl as QRLInternal<() => T>); }; +/** + * @public + */ export const isSignal2 = (value: any): value is ISignal2 => { return value instanceof Signal2; }; diff --git a/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx b/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx index db13418b109..20519db33af 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx +++ b/packages/qwik/src/core/v2/signal/v2-signal.unit.tsx @@ -12,7 +12,7 @@ import { isPromise } from '../../util/promises'; import { getDomContainer } from '../client/dom-container'; import { ChoreType } from '../shared/scheduler'; import type { Container2 } from '../shared/types'; -import type { EffectSubscriptions } from './v2-signal'; +import { EffectProperty, type EffectSubscriptions } from './v2-signal'; import { createComputed2Qrl, createSignal2, type ReadonlySignal2 } from './v2-signal.public'; describe('v2-signal', () => { @@ -163,7 +163,7 @@ describe('v2-signal', () => { } else { const ctx = newInvokeContext(); ctx.$container2$ = container; - const subscriber: EffectSubscriptions = [task, ctx]; + const subscriber: EffectSubscriptions = [task, EffectProperty.COMPONENT, ctx]; ctx.$effectSubscriber$ = subscriber; return invoke(ctx, qrl.getFn(ctx)); } diff --git a/packages/qwik/src/core/v2/tests/use-store.spec.tsx b/packages/qwik/src/core/v2/tests/use-store.spec.tsx index 053a7d4bb08..0ef8a96aac4 100644 --- a/packages/qwik/src/core/v2/tests/use-store.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-store.spec.tsx @@ -14,7 +14,7 @@ const debug = true; //true; Error.stackTraceLimit = 100; describe.each([ - // { render: ssrRenderToDom }, // + { render: ssrRenderToDom }, // { render: domRender }, // ])('$render.name: useStore', ({ render }) => { it('should render value', async () => { diff --git a/packages/qwik/src/server/v2-ssr-container.ts b/packages/qwik/src/server/v2-ssr-container.ts index d09a1b63150..bb2620b6daa 100644 --- a/packages/qwik/src/server/v2-ssr-container.ts +++ b/packages/qwik/src/server/v2-ssr-container.ts @@ -1073,14 +1073,14 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { value.value = lastNode; continue; } else { - value = this.trackSignalValue(value, lastNode as fixMeAny, key, [ + value = (this.trackSignalValue as fixMeAny)(value, lastNode as fixMeAny, key, [ immutable ? SubscriptionType.PROP_IMMUTABLE : SubscriptionType.PROP_MUTABLE, lastNode as fixMeAny, value, lastNode as fixMeAny, key, styleScopedId || undefined, - ]); + ] as fixMeAny); } } From 2d923a6895757ca2a1a710e6a0f369e9056ce03b Mon Sep 17 00:00:00 2001 From: Varixo Date: Sun, 28 Jul 2024 14:31:51 +0200 Subject: [PATCH 21/89] fix use-signal tests --- packages/qwik/src/core/v2/shared/scheduler.ts | 10 +- packages/qwik/src/core/v2/signal/v2-signal.ts | 92 +++++++++++-------- packages/qwik/src/server/v2-ssr-container.ts | 11 +-- 3 files changed, 62 insertions(+), 51 deletions(-) diff --git a/packages/qwik/src/core/v2/shared/scheduler.ts b/packages/qwik/src/core/v2/shared/scheduler.ts index 805d3d891b2..1b67babb7e3 100644 --- a/packages/qwik/src/core/v2/shared/scheduler.ts +++ b/packages/qwik/src/core/v2/shared/scheduler.ts @@ -222,7 +222,11 @@ export const createScheduler = ( } let chore: Chore = { $type$: type, - $idx$: isTask ? (hostOrTask as Task).$index$ : (typeof targetOrQrl === 'string' ? targetOrQrl : 0), + $idx$: isTask + ? (hostOrTask as Task).$index$ + : typeof targetOrQrl === 'string' + ? targetOrQrl + : 0, $host$: isTask ? ((hostOrTask as Task).$el$ as fixMeAny) : (hostOrTask as HostElement), $target$: targetOrQrl as any, $payload$: isTask ? hostOrTask : payload, @@ -321,7 +325,7 @@ export const createScheduler = ( const task = chore.$payload$ as Task; cleanupTask(task); break; - case ChoreType.NODE_DIFF: + case ChoreType.NODE_DIFF: const parentVirtualNode = chore.$target$ as VirtualVNode; let jsx = chore.$payload$ as JSXOutput; if (isSignal2(jsx)) { @@ -521,7 +525,7 @@ function debugTrace( if (arg) { lines.push( ' arg: ' + - ('$type$' in arg ? debugChoreToString(arg as Chore) : String(arg).replaceAll(/\n.*/gim, '')) + ('$type$' in arg ? debugChoreToString(arg as Chore) : String(arg).replaceAll(/\n.*/gim, '')) ); } if (currentChore) { diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index a321fefe52f..c85abf55932 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -16,10 +16,7 @@ import { pad, qwikDebugToString } from '../../debug'; import { assertDefined, assertFalse, assertTrue } from '../../error/assert'; import { type QRLInternal } from '../../qrl/qrl-class'; import type { QRL } from '../../qrl/qrl.public'; -import { - trackSignal2, - tryGetInvokeContext -} from '../../use/use-core'; +import { trackSignal2, tryGetInvokeContext } from '../../use/use-core'; import { Task, TaskFlags, isTask } from '../../use/use-task'; import { ELEMENT_PROPS, OnRenderProp } from '../../util/markers'; import { isPromise } from '../../util/promises'; @@ -27,6 +24,7 @@ import { qDev } from '../../util/qdev'; import type { VNode } from '../client/types'; import { ChoreType } from '../shared/scheduler'; import type { Container2, HostElement, fixMeAny } from '../shared/types'; +import type { ISsrNode } from '../ssr/ssr-types'; import type { Signal2 as ISignal2 } from './v2-signal.public'; import type { Store2 } from './v2-store'; @@ -41,7 +39,7 @@ const NEEDS_COMPUTATION: any = { }; // eslint-disable-next-line no-console -const log = (...args: any[]) => console.log('SIGNAL', ...(args).map(qwikDebugToString)); +const log = (...args: any[]) => console.log('SIGNAL', ...args.map(qwikDebugToString)); export const createSignal2 = (value?: any) => { return new Signal2(null, value); @@ -60,9 +58,7 @@ export const createComputedSignal2 = (qrl: QRL<() => T>) => { return new ComputedSignal2(null, qrl as QRLInternal<() => T>); }; -/** - * @public - */ +/** @public */ export const isSignal2 = (value: any): value is ISignal2 => { return value instanceof Signal2; }; @@ -73,10 +69,10 @@ export const isSignal2 = (value: any): value is ISignal2 => { * There are three types of effects: * * - `Task`: `useTask`, `useVisibleTask`, `useResource` - * - `VNode`: Either a component or `` + * - `VNode` and `ISsrNode`: Either a component or `` * - `Signal2`: A derived signal which contains a computation function. */ -export type Effect = Task | VNode | Signal2; +export type Effect = Task | VNode | ISsrNode | Signal2; /** * An effect plus a list of subscriptions effect depends on. @@ -85,7 +81,6 @@ export type Effect = Task | VNode | Signal2; * is to clear its subscriptions so that the effect can re add new set of subscriptions. In order to * clear the subscriptions we need to store them here. * - * * Imagine you have effect such as: * * ``` @@ -108,20 +103,24 @@ export type Effect = Task | VNode | Signal2; * Both `signalA` as well as `signalB` will have a reference to `subscription` to the so that the * effect can be scheduled if either `signalA` or `signalB` triggers. The `subscription1` is shared * between the signals. - * + * * The second position `string|boolean` store the property name of the effect. - * - property name of the VNode - * - `EffectProperty.COMPONENT` if component - * - `EffectProperty.VNODE` if VNode + * + * - Property name of the VNode + * - `EffectProperty.COMPONENT` if component + * - `EffectProperty.VNODE` if VNode */ export type EffectSubscriptions = [ Effect, // EffectSubscriptionsProp.EFFECT - string, // EffectSubscriptionsProp.PROPERTY - ...( // NOTE even thought this is shown as `...(string|Signal2)` - // it is a list of strings followed by a list of signals (not intermingled) - string | // List of properties (Only used with Store2 (not with Signal2)) - Signal2 | Store2 // List of signals to release - )[]]; + string, // EffectSubscriptionsProp.PROPERTY + ...// NOTE even thought this is shown as `...(string|Signal2)` + // it is a list of strings followed by a list of signals (not intermingled) + ( + | string // List of properties (Only used with Store2 (not with Signal2)) + | Signal2 + | Store2 // List of signals to release + )[], +]; export const enum EffectSubscriptionsProp { EFFECT = 0, PROPERTY = 1, @@ -129,7 +128,7 @@ export const enum EffectSubscriptionsProp { } export const enum EffectProperty { COMPONENT = ':', - VNODE = '.' + VNODE = '.', } export class Signal2 implements ISignal2 { @@ -180,7 +179,7 @@ export class Signal2 implements ISignal2 { // to unsubscribe from. So we need to store the reference from the effect back // to this signal. ensureContains(effectSubscriber, this); - DEBUG && log("read->sub", pad('\n' + this.toString(), " ")) + DEBUG && log('read->sub', pad('\n' + this.toString(), ' ')); } } return this.untrackedValue; @@ -188,13 +187,13 @@ export class Signal2 implements ISignal2 { set value(value) { if (value !== this.$untrackedValue$) { - DEBUG && log('Signal.set', this.$untrackedValue$, '->', value, pad('\n' + this.toString(), " ")); + DEBUG && + log('Signal.set', this.$untrackedValue$, '->', value, pad('\n' + this.toString(), ' ')); this.$untrackedValue$ = value; triggerEffects(this.$container$, this, this.$effects$); } } - // prevent accidental use as value valueOf() { if (qDev) { @@ -202,8 +201,10 @@ export class Signal2 implements ISignal2 { } } toString() { - return `[${this.constructor.name}${(this as any).$invalid$ ? " INVALID" : ''} ${String(this.$untrackedValue$)}]` + - this.$effects$?.map(e => '\n -> ' + pad(qwikDebugToString(e[0]), ' ')).join('\n') || ''; + return ( + `[${this.constructor.name}${(this as any).$invalid$ ? ' INVALID' : ''} ${String(this.$untrackedValue$)}]` + + this.$effects$?.map((e) => '\n -> ' + pad(qwikDebugToString(e[0]), ' ')).join('\n') || '' + ); } toJSON() { return { value: this.$untrackedValue$ }; @@ -216,7 +217,7 @@ export const ensureContains = (array: any[], value: any) => { if (isMissing) { array.push(value); } -} +}; export const ensureContainsEffect = (array: EffectSubscriptions[], effect: EffectSubscriptions) => { for (let i = 0; i < array.length; i++) { @@ -226,9 +227,13 @@ export const ensureContainsEffect = (array: EffectSubscriptions[], effect: Effec } } array.push(effect); -} +}; -export const triggerEffects = (container: Container2 | null, signal: Signal2 | Store2, effects: EffectSubscriptions[] | null) => { +export const triggerEffects = ( + container: Container2 | null, + signal: Signal2 | Store2, + effects: EffectSubscriptions[] | null +) => { if (effects) { const scheduleEffect = (effectSubscriptions: EffectSubscriptions) => { const effect = effectSubscriptions[EffectSubscriptionsProp.EFFECT]; @@ -237,8 +242,10 @@ export const triggerEffects = (container: Container2 | null, signal: Signal2 | S if (isTask(effect)) { effect.$flags$ |= TaskFlags.DIRTY; DEBUG && log('schedule.effect.task', pad('\n' + String(effect), ' ')); - // TODO(mhevery): We should check if visible/resource task and scheduled differently. - container.$scheduler$(ChoreType.TASK, effectSubscriptions as fixMeAny); + container.$scheduler$( + effect.$flags$ & TaskFlags.VISIBLE_TASK ? ChoreType.VISIBLE : ChoreType.TASK, + effectSubscriptions as fixMeAny + ); } else if (effect instanceof Signal2) { // we don't schedule ComputedSignal/DerivedSignal directly, instead we invalidate it and // and schedule the signals effects (recursively) @@ -273,8 +280,7 @@ export const triggerEffects = (container: Container2 | null, signal: Signal2 | S } DEBUG && log('done scheduling'); -} - +}; /** * A signal which is computed from other signals. @@ -325,7 +331,7 @@ export class ComputedSignal2 extends Signal2 { get untrackedValue() { this.$computeIfNeeded$(); - assertFalse(this.$untrackedValue$ === NEEDS_COMPUTATION, 'Invalid state') + assertFalse(this.$untrackedValue$ === NEEDS_COMPUTATION, 'Invalid state'); return this.$untrackedValue$; } @@ -382,7 +388,12 @@ export class DerivedSignal2 extends Signal2 { // we need the old value to know if effects need running after computation $invalid$: boolean = true; - constructor(container: Container2 | null, fn: (...args: any[]) => T, args: any[], fnStr: string | null) { + constructor( + container: Container2 | null, + fn: (...args: any[]) => T, + args: any[], + fnStr: string | null + ) { super(container, NEEDS_COMPUTATION); this.$args$ = args; this.$func$ = fn; @@ -417,7 +428,7 @@ export class DerivedSignal2 extends Signal2 { return this.$func$(...this.$args$); } this.$computeIfNeeded$(); - assertFalse(this.$untrackedValue$ === NEEDS_COMPUTATION, 'Invalid state') + assertFalse(this.$untrackedValue$ === NEEDS_COMPUTATION, 'Invalid state'); return this.$untrackedValue$; } @@ -425,7 +436,12 @@ export class DerivedSignal2 extends Signal2 { if (!this.$invalid$) { return false; } - this.$untrackedValue$ = trackSignal2(() => this.$func$(...this.$args$), this, EffectProperty.VNODE, this.$container$!); + this.$untrackedValue$ = trackSignal2( + () => this.$func$(...this.$args$), + this, + EffectProperty.VNODE, + this.$container$! + ); } // Getters don't get inherited diff --git a/packages/qwik/src/server/v2-ssr-container.ts b/packages/qwik/src/server/v2-ssr-container.ts index bb2620b6daa..3df7aa3a039 100644 --- a/packages/qwik/src/server/v2-ssr-container.ts +++ b/packages/qwik/src/server/v2-ssr-container.ts @@ -28,7 +28,6 @@ import { QStyle, QContainerAttr, QTemplate, - SubscriptionType, VNodeDataChar, VirtualType, convertStyleIdsToString, @@ -61,7 +60,6 @@ import { type StreamWriter, type SymbolToChunkResolver, type ValueOrPromise, - type fixMeAny, } from './qwik-types'; import { Q_FUNCS_PREFIX } from './render'; import type { PrefetchResource, RenderOptions, RenderToStreamResult } from './types'; @@ -1073,14 +1071,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { value.value = lastNode; continue; } else { - value = (this.trackSignalValue as fixMeAny)(value, lastNode as fixMeAny, key, [ - immutable ? SubscriptionType.PROP_IMMUTABLE : SubscriptionType.PROP_MUTABLE, - lastNode as fixMeAny, - value, - lastNode as fixMeAny, - key, - styleScopedId || undefined, - ] as fixMeAny); + value = this.trackSignalValue(value, lastNode, key); } } From 289ab11ea3716afb6624f40a33572c4bed71967b Mon Sep 17 00:00:00 2001 From: Varixo Date: Sun, 28 Jul 2024 18:02:59 +0200 Subject: [PATCH 22/89] fix some visible task tests --- .../core/v2/shared/shared-serialization.ts | 56 ++++++++++++------- .../core/v2/tests/use-visible-task.spec.tsx | 19 ++++--- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/packages/qwik/src/core/v2/shared/shared-serialization.ts b/packages/qwik/src/core/v2/shared/shared-serialization.ts index 9330b2c98a0..49984d681fa 100644 --- a/packages/qwik/src/core/v2/shared/shared-serialization.ts +++ b/packages/qwik/src/core/v2/shared/shared-serialization.ts @@ -25,11 +25,11 @@ import { fastSkipSerialize, getProxyFlags, getSubscriptionManager, - unwrapProxy + unwrapProxy, } from '../../state/common'; import { _CONST_PROPS, _VAR_PROPS } from '../../state/constants'; import { getOrCreateProxy, isStore } from '../../state/store'; -import { Task, type ResourceReturnInternal } from '../../use/use-task'; +import { isTask, Task, type ResourceReturnInternal } from '../../use/use-task'; import { throwErrorAndStop } from '../../util/log'; import { ELEMENT_ID } from '../../util/markers'; import { isPromise } from '../../util/promises'; @@ -839,21 +839,25 @@ function serialize(serializationContext: SerializationContext): void { if (value instanceof DerivedSignal2) { writeString( SerializationConstant.DerivedSignal_CHAR + - serializeDerivedFn(serializationContext, value, $addRoot$) + - ';' + - $addRoot$(value.$untrackedValue$) + - serializeEffectSubs($addRoot$, value.$effects$) + serializeDerivedFn(serializationContext, value, $addRoot$) + + ';' + + $addRoot$(value.$untrackedValue$) + + serializeEffectSubs($addRoot$, value.$effects$) ); } else if (value instanceof ComputedSignal2) { - writeString(SerializationConstant.ComputedSignal_CHAR - + qrlToString(serializationContext, value.$computeQrl$) - + ';' - + ($addRoot$(value.$untrackedValue$)) - + serializeEffectSubs($addRoot$, value.$effects$)) + writeString( + SerializationConstant.ComputedSignal_CHAR + + qrlToString(serializationContext, value.$computeQrl$) + + ';' + + $addRoot$(value.$untrackedValue$) + + serializeEffectSubs($addRoot$, value.$effects$) + ); } else { - writeString(SerializationConstant.Signal_CHAR - + ($addRoot$(value.$untrackedValue$)) - + (serializeEffectSubs($addRoot$, value.$effects$))); + writeString( + SerializationConstant.Signal_CHAR + + $addRoot$(value.$untrackedValue$) + + serializeEffectSubs($addRoot$, value.$effects$) + ); } } else if (value instanceof URL) { writeString(SerializationConstant.URL_CHAR + value.href); @@ -1063,19 +1067,20 @@ function deserializeSignal2( container: DomContainer, data: string, readFn: boolean, - readQrl: boolean, + readQrl: boolean ) { signal.$container$ = container; const parts = data.substring(1).split(';'); let idx = 0; if (readFn) { - const derivedSignal = signal as DerivedSignal2 + const derivedSignal = signal as DerivedSignal2; derivedSignal.$invalid$ = false; const fnParts = parts[idx++].split(' '); derivedSignal.$func$ = container.getSyncFn(parseInt(fnParts[0])); for (let i = 1; i < fnParts.length; i++) { - (derivedSignal.$args$ || (derivedSignal.$args$ = [])) - .push(container.$getObjectById$(parseInt(fnParts[i]))); + (derivedSignal.$args$ || (derivedSignal.$args$ = [])).push( + container.$getObjectById$(parseInt(fnParts[i])) + ); } } if (readQrl) { @@ -1085,7 +1090,9 @@ function deserializeSignal2( signal.$untrackedValue$ = container.$getObjectById$(parts[idx++]); while (idx < parts.length) { // idx == 1 is the attribute name - const effect = parts[idx++].split(' ').map((obj, idx) => idx == 1 ? obj : container.$getObjectById$(obj)); + const effect = parts[idx++] + .split(' ') + .map((obj, idx) => (idx == 1 ? obj : container.$getObjectById$(obj))); (signal.$effects$ || (signal.$effects$ = [])).push(effect as fixMeAny); } } @@ -1201,7 +1208,12 @@ const frameworkType = (obj: any) => { }; export const canSerialize2 = (value: any): boolean => { - if (value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + if ( + value == null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { return true; } else if (typeof value === 'object') { let proto = Object.getPrototypeOf(value); @@ -1223,6 +1235,8 @@ export const canSerialize2 = (value: any): boolean => { } } return true; + } else if (isTask(value)) { + return true; } } else if (typeof value === 'function') { if (isQrl(value) || isQwikComponent(value)) { @@ -1230,7 +1244,7 @@ export const canSerialize2 = (value: any): boolean => { } } return false; -} +}; const QRL_RUNTIME_CHUNK = 'qwik-runtime-mock-chunk'; const SERIALIZABLE_ROOT_ID = Symbol('SERIALIZABLE_ROOT_ID'); diff --git a/packages/qwik/src/core/v2/tests/use-visible-task.spec.tsx b/packages/qwik/src/core/v2/tests/use-visible-task.spec.tsx index d3c50be7afa..89930de30f2 100644 --- a/packages/qwik/src/core/v2/tests/use-visible-task.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-visible-task.spec.tsx @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { trigger } from '../../../testing/element-fixture'; -import { ErrorProvider, domRender, ssrRenderToDom } from '../../../testing/rendering.unit-util'; -import '../../../testing/vdom-diff.unit-util'; -import { component$ } from '../../component/component.public'; -import { Fragment as Component, Fragment, Fragment as Signal } from '../../render/jsx/jsx-runtime'; -import { useSignal } from '../../use/use-signal'; -import { useStore } from '../../use/use-store.public'; -import { useVisibleTask$ } from '../../use/use-task-dollar'; +import { + component$, + Fragment as Component, + Fragment, + Fragment as Signal, + useSignal, + useStore, + useVisibleTask$, +} from '@builder.io/qwik'; +import { trigger, domRender, ssrRenderToDom } from '@builder.io/qwik/testing'; +import { ErrorProvider } from '../../../testing/rendering.unit-util'; import { delay } from '../../util/promises'; const debug = false; //true; From 4f6045fc2a58ddd93dc53b174d49b5324102571c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Tue, 30 Jul 2024 17:06:27 -0700 Subject: [PATCH 23/89] wip: use-store.spec passes in CSR mode --- packages/qwik/src/core/debug.ts | 39 ++++++ packages/qwik/src/core/util/types.ts | 2 +- packages/qwik/src/core/v2/shared/scheduler.ts | 2 +- .../core/v2/shared/shared-serialization.ts | 4 +- packages/qwik/src/core/v2/signal/v2-signal.ts | 3 + packages/qwik/src/core/v2/signal/v2-store.ts | 111 ++++++++++++------ .../qwik/src/core/v2/tests/use-store.spec.tsx | 15 ++- 7 files changed, 131 insertions(+), 45 deletions(-) diff --git a/packages/qwik/src/core/debug.ts b/packages/qwik/src/core/debug.ts index b3339f8a249..ecb2f575a14 100644 --- a/packages/qwik/src/core/debug.ts +++ b/packages/qwik/src/core/debug.ts @@ -1,6 +1,9 @@ import { isQrl } from "../server/prefetch-strategy"; +import { isJSXNode } from "./render/jsx/jsx-runtime"; import { isTask } from "./use/use-task"; import { vnode_isVNode, vnode_toString } from "./v2/client/vnode"; +import { isSignal2 } from "./v2/signal/v2-signal"; +import { isStore2 } from "./v2/signal/v2-store"; const stringifyPath: any[] = []; export function qwikDebugToString(value: any): any { @@ -27,6 +30,10 @@ export function qwikDebugToString(value: any): any { } else { return value.map(qwikDebugToString); } + } else if (isStore2(value) || isSignal2(value)) { + return value.toString(); + } else if (isJSXNode(value)) { + return jsxToString(value); } else if (isTask(value)) { return `Task(${qwikDebugToString(value.$qrl$)})` } else if (isQrl(value)) { @@ -43,4 +50,36 @@ export function qwikDebugToString(value: any): any { export const pad = (text: string, prefix: string) => { return String(text).split('\n').map((line, idx) => (idx ? prefix : '') + line).join('\n'); +} + +export const jsxToString = (value: any): string => { + if (isJSXNode(value)) { + let type = value.type; + if (typeof type === 'function') { + type = type.name || 'Component'; + } + let str = '<' + value.type; + if (value.props) { + for (const [key, val] of Object.entries(value.props)) { + str += ' ' + key + '=' + qwikDebugToString(val); + } + const children = value.children; + if (children != null) { + str += '>'; + if (Array.isArray(children)) { + children.forEach((child) => { + str += jsxToString(child); + }); + } else { + str += jsxToString(children); + } + str += ''; + } else { + str += '/>'; + } + } + return str; + } else { + return String(value); + } } \ No newline at end of file diff --git a/packages/qwik/src/core/util/types.ts b/packages/qwik/src/core/util/types.ts index ec8d09610d8..20567d85822 100644 --- a/packages/qwik/src/core/util/types.ts +++ b/packages/qwik/src/core/util/types.ts @@ -5,7 +5,7 @@ export const isHtmlElement = (node: unknown): node is Element => { export const isSerializableObject = (v: unknown): v is Record => { const proto = Object.getPrototypeOf(v); - return proto === Object.prototype || proto === null; + return proto === Object.prototype || proto === Array.prototype || proto === null; }; export const isObject = (v: unknown): v is object => { diff --git a/packages/qwik/src/core/v2/shared/scheduler.ts b/packages/qwik/src/core/v2/shared/scheduler.ts index 1b67babb7e3..af4ff87d204 100644 --- a/packages/qwik/src/core/v2/shared/scheduler.ts +++ b/packages/qwik/src/core/v2/shared/scheduler.ts @@ -103,7 +103,7 @@ import { EffectSubscriptionsProp, isSignal2, type EffectSubscriptions } from '.. import { serializeAttribute } from '../../render/execute-component'; // Turn this on to get debug output of what the scheduler is doing. -const DEBUG: boolean = false; +const DEBUG: boolean = true; export const enum ChoreType { /// MASKS defining three levels of sorting diff --git a/packages/qwik/src/core/v2/shared/shared-serialization.ts b/packages/qwik/src/core/v2/shared/shared-serialization.ts index 49984d681fa..990defe74df 100644 --- a/packages/qwik/src/core/v2/shared/shared-serialization.ts +++ b/packages/qwik/src/core/v2/shared/shared-serialization.ts @@ -29,7 +29,7 @@ import { } from '../../state/common'; import { _CONST_PROPS, _VAR_PROPS } from '../../state/constants'; import { getOrCreateProxy, isStore } from '../../state/store'; -import { isTask, Task, type ResourceReturnInternal } from '../../use/use-task'; +import { Task, isTask, type ResourceReturnInternal } from '../../use/use-task'; import { throwErrorAndStop } from '../../util/log'; import { ELEMENT_ID } from '../../util/markers'; import { isPromise } from '../../util/promises'; @@ -43,9 +43,9 @@ import { Signal2, type EffectSubscriptions, } from '../signal/v2-signal'; +import { Store2, unwrapStore2 } from '../signal/v2-store'; import type { SymbolToChunkResolver } from '../ssr/ssr-types'; import type { fixMeAny } from './types'; -import { Store2, unwrapStore2 } from '../signal/v2-store'; const deserializedProxyMap = new WeakMap(); diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index c85abf55932..4c56d17ea3f 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -200,6 +200,7 @@ export class Signal2 implements ISignal2 { throw new TypeError('Cannot coerce a Signal, use `.value` instead'); } } + toString() { return ( `[${this.constructor.name}${(this as any).$invalid$ ? ' INVALID' : ''} ${String(this.$untrackedValue$)}]` + @@ -226,6 +227,8 @@ export const ensureContainsEffect = (array: EffectSubscriptions[], effect: Effec return; } } + console.log('array', array); + console.log('array.push', array.push); array.push(effect); }; diff --git a/packages/qwik/src/core/v2/signal/v2-store.ts b/packages/qwik/src/core/v2/signal/v2-store.ts index 96cd7828990..01dd37d58f9 100644 --- a/packages/qwik/src/core/v2/signal/v2-store.ts +++ b/packages/qwik/src/core/v2/signal/v2-store.ts @@ -1,15 +1,21 @@ -import { pad, qwikDebugToString } from "../../debug"; -import { assertDefined, assertTrue } from "../../error/assert"; -import { tryGetInvokeContext } from "../../use/use-core"; -import type { VNode } from "../client/types"; -import type { Container2, fixMeAny } from "../shared/types"; -import { EffectProperty, ensureContains, ensureContainsEffect, triggerEffects, type EffectSubscriptions } from "./v2-signal"; - -const DEBUG = false; +import { pad, qwikDebugToString } from '../../debug'; +import { assertDefined, assertTrue } from '../../error/assert'; +import { tryGetInvokeContext } from '../../use/use-core'; +import { isObject, isSerializableObject } from '../../util/types'; +import type { VNode } from '../client/types'; +import type { Container2, fixMeAny } from '../shared/types'; +import { + EffectProperty, + ensureContains, + ensureContainsEffect, + triggerEffects, + type EffectSubscriptions, +} from './v2-signal'; + +const DEBUG = true; // eslint-disable-next-line no-console -const log = (...args: any[]) => console.log('STORE', ...(args).map(qwikDebugToString)); - +const log = (...args: any[]) => console.log('STORE', ...args.map(qwikDebugToString)); const storeWeakMap = new WeakMap>(); @@ -22,33 +28,44 @@ export const enum Store2Flags { } export type Store2 = T & { - __BRAND__: 'Store' + __BRAND__: 'Store'; }; let _lastTarget: undefined | StoreHandler; export const getStoreTarget2 = (value: T): T | null => { _lastTarget = undefined as any; - return typeof value === 'object' && value && (STORE in value) // this implicitly sets the `_lastTarget` as a side effect. - ? _lastTarget!.$target$ as T : null; -} + return typeof value === 'object' && value && STORE in value // this implicitly sets the `_lastTarget` as a side effect. + ? (_lastTarget!.$target$ as T) + : null; +}; export const unwrapStore2 = (value: T): T => { - return getStoreTarget2(value as fixMeAny) as T || value; -} + return (getStoreTarget2(value as fixMeAny) as T) || value; +}; export const isStore2 = (value: T): value is Store2 => { return value instanceof Store; -} +}; -export const getOrCreateStore2 = (obj: T, flags: Store2Flags, container?: Container2 | null): Store2 => { - let store: Store2 | undefined = storeWeakMap.get(obj) as Store2 | undefined; - if (!store) { - store = new Proxy(new Store(), new StoreHandler(obj, flags, container || null)) as Store2; - storeWeakMap.set(obj, store as any); +export const getOrCreateStore2 = ( + obj: T, + flags: Store2Flags, + container?: Container2 | null +): Store2 => { + if (isSerializableObject(obj)) { + let store: Store2 | undefined = storeWeakMap.get(obj) as Store2 | undefined; + if (!store) { + store = new Proxy( + new Store(), + new StoreHandler(obj, flags, container || null) + ) as Store2; + storeWeakMap.set(obj, store as any); + } + return store as Store2; } - return store as Store2; -} + return obj as any; +}; class Store { toString() { @@ -59,8 +76,33 @@ class Store { export const Store2 = Store; class StoreHandler> implements ProxyHandler { - $effects$: null | EffectSubscriptions[] = null; - constructor(public $target$: T, public $flags$: Store2Flags, public $container$: Container2 | null) { + $effects$: null | Record = null; + constructor( + public $target$: T, + public $flags$: Store2Flags, + public $container$: Container2 | null + ) { } + + toString() { + const flags = []; + if (this.$flags$ & Store2Flags.RECURSIVE) { + flags.push('RECURSIVE'); + } + if (this.$flags$ & Store2Flags.IMMUTABLE) { + flags.push('IMMUTABLE'); + } + let str = '[Store: ' + flags.join('|') + '\n'; + for (const key in this.$target$) { + const value = this.$target$[key]; + str += ' ' + key + ': ' + qwikDebugToString(value) + ',\n'; + const effects = this.$effects$?.[key]; + effects?.forEach(([effect, prop, ...subs]) => { + str += ' ' + qwikDebugToString(effect) + '\n'; + str += ' ' + qwikDebugToString(prop) + '\n'; + // str += ' ' + subs.map(qwikDebugToString).join(';') + '\n'; + }); + } + return str + ']'; } get(_: T, p: string | symbol) { @@ -85,7 +127,10 @@ class StoreHandler> implements ProxyHandl } } if (effectSubscriber) { - const effects = (this.$effects$ ||= []); + const effectsMap = (this.$effects$ ||= {}); + const effects = + (Object.prototype.hasOwnProperty.call(effectsMap, p) && effectsMap[p as fixMeAny]) || + (effectsMap[p as fixMeAny] = []); // Let's make sure that we have a reference to this effect. // Adding reference is essentially adding a subscription, so if the signal // changes we know who to notify. @@ -94,27 +139,27 @@ class StoreHandler> implements ProxyHandl // to unsubscribe from. So we need to store the reference from the effect back // to this signal. ensureContains(effectSubscriber, this); - DEBUG && log("read->sub", pad('\n' + this.toString(), " ")) + DEBUG && log('read->sub', pad('\n' + this.toString(), ' ')); } } let value = target[p]; if (p === 'toString' && value === Object.prototype.toString) { return Store.prototype.toString; } - if (typeof value === 'object' && value !== null) { + const flags = this.$flags$; + if (flags & Store2Flags.RECURSIVE && typeof value === 'object' && value !== null) { value = getOrCreateStore2(value, this.$flags$, this.$container$); } return value; } - set(_: T, p: string | symbol, value: any): boolean { const target = this.$target$; const oldValue = target[p]; if (value !== oldValue) { - DEBUG && log('Signal.set', oldValue, '->', value, pad('\n' + this.toString(), " ")); + DEBUG && log('Signal.set', oldValue, '->', value, pad('\n' + this.toString(), ' ')); (target as any)[p] = value; - triggerEffects(this.$container$, this, this.$effects$); + triggerEffects(this.$container$, this, this.$effects$?.[String(p)]); } return true; } @@ -137,4 +182,4 @@ class StoreHandler> implements ProxyHandl ownKeys(): ArrayLike { return Reflect.ownKeys(this.$target$); } -} \ No newline at end of file +} diff --git a/packages/qwik/src/core/v2/tests/use-store.spec.tsx b/packages/qwik/src/core/v2/tests/use-store.spec.tsx index 0ef8a96aac4..fc71330d207 100644 --- a/packages/qwik/src/core/v2/tests/use-store.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-store.spec.tsx @@ -1,4 +1,4 @@ -import { Fragment as Component, Fragment, Fragment as Signal } from '@builder.io/qwik'; +import { Fragment as Component, Fragment, Fragment as Signal, useTask$ } from '@builder.io/qwik'; import { describe, expect, it, vi } from 'vitest'; import { advanceToNextTimerAndFlush, trigger } from '../../../testing/element-fixture'; import { domRender, ssrRenderToDom } from '../../../testing/rendering.unit-util'; @@ -8,13 +8,12 @@ import type { Signal as SignalType } from '../../state/signal'; import { untrack } from '../../use/use-core'; import { useSignal } from '../../use/use-signal'; import { useStore } from '../../use/use-store.public'; -import { useTask$ } from '../../use/use-task-dollar'; const debug = true; //true; Error.stackTraceLimit = 100; describe.each([ - { render: ssrRenderToDom }, // + // { render: ssrRenderToDom }, // { render: domRender }, // ])('$render.name: useStore', ({ render }) => { it('should render value', async () => { @@ -184,16 +183,16 @@ describe.each([ ); }); - it.only('should allow signal to deliver value or JSX', async () => { + it('should allow signal to deliver value or JSX', async () => { const log: string[] = []; const Counter = component$(() => { - const count = useStore({ value: 'initial' }); - log.push('Counter: ' + untrack(() => count.value)); + const count = useStore({ jsx: 'initial' }); + log.push('Counter: ' + untrack(() => count.jsx)); return ( ); }); From 6a6b6097d91000323d8c75ea85b703249dd5ad03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=A1ko=20Hevery?= Date: Thu, 1 Aug 2024 17:30:32 -0700 Subject: [PATCH 24/89] WIP: basic serialization/deserialization of Store2 working --- .../core/v2/shared/shared-serialization.ts | 67 +++++++++++++------ packages/qwik/src/core/v2/signal/v2-store.ts | 33 +++++---- .../qwik/src/core/v2/tests/use-store.spec.tsx | 6 +- 3 files changed, 69 insertions(+), 37 deletions(-) diff --git a/packages/qwik/src/core/v2/shared/shared-serialization.ts b/packages/qwik/src/core/v2/shared/shared-serialization.ts index 990defe74df..ca6401faec9 100644 --- a/packages/qwik/src/core/v2/shared/shared-serialization.ts +++ b/packages/qwik/src/core/v2/shared/shared-serialization.ts @@ -24,12 +24,11 @@ import { Slot } from '../../render/jsx/slot.public'; import { fastSkipSerialize, getProxyFlags, - getSubscriptionManager, - unwrapProxy, + getSubscriptionManager } from '../../state/common'; import { _CONST_PROPS, _VAR_PROPS } from '../../state/constants'; -import { getOrCreateProxy, isStore } from '../../state/store'; import { Task, isTask, type ResourceReturnInternal } from '../../use/use-task'; +import { EMPTY_OBJ } from '../../util/flyweight'; import { throwErrorAndStop } from '../../util/log'; import { ELEMENT_ID } from '../../util/markers'; import { isPromise } from '../../util/promises'; @@ -43,7 +42,7 @@ import { Signal2, type EffectSubscriptions, } from '../signal/v2-signal'; -import { Store2, unwrapStore2 } from '../signal/v2-store'; +import { Store2, createStore2, getStoreHandler2, unwrapStore2, type StoreHandler } from '../signal/v2-store'; import type { SymbolToChunkResolver } from '../ssr/ssr-types'; import type { fixMeAny } from './types'; @@ -114,14 +113,6 @@ class DeserializationHandler implements ProxyHandler { propValue === SerializationConstant.VNode_CHAR ? container.element.ownerDocument : vnode_locate(container.rootVNode, propValue.substring(1)); - } else if (typeCode === SerializationConstant.Store_VALUE) { - // Special case of Store. - // Stores are proxies, Proxies need to get their target eagerly. So we can't use inflate() - // because that would not allow us to get a hold of the target. - const target = container.$getObjectById$(propValue.substring(1)) as { - [SerializationConstant.Store_CHAR]: string | undefined; - }; - propValue = getOrCreateProxy(target, container); } else if (typeCode === SerializationConstant.DerivedSignal_VALUE && !Array.isArray(target)) { // Special case of derived signal. We need to create a [_CONST_PROPS] property. return wrapDeserializerProxy( @@ -274,6 +265,21 @@ const inflate = (container: DomContainer, target: any, needsInflationData: strin inflateQRL(container, target[SERIALIZABLE_STATE][0]); break; case SerializationConstant.Store_VALUE: + const storeHandler = getStoreHandler2(target)!; + storeHandler.$container$ = container; + storeHandler.$target$ = container.$getObjectById$(restInt()); + storeHandler.$flags$ = restInt(); + const effectProps = rest.substring(restIdx).split('|'); + if (effectProps.length) { + const effects: Record = (storeHandler.$effects$ = {}); + for (let i = 0; i < effectProps.length; i++) { + const effect = effectProps[i]; + const idx = effect.indexOf(';'); + const prop = effect.substring(0, idx); + const effectStr = effect.substring(idx + 1); + deserializeSignal2Effect(0, effectStr.split(';'), container, effects[prop] = []) + } + } break; case SerializationConstant.Signal_VALUE: deserializeSignal2(target as Signal2, container, rest, false, false); @@ -413,6 +419,8 @@ const allocate = (value: string): any => { return new Uint8Array(decodedLength); case SerializationConstant.PropsProxy_VALUE: return createPropsProxy(null!, null); + case SerializationConstant.Store_VALUE: + return createStore2(null, EMPTY_OBJ, 0); default: throw new Error('unknown allocate type: ' + value.charCodeAt(0)); } @@ -633,9 +641,6 @@ export const createSerializationContext = ( // As we walk the object graph we insert newly discovered objects which need to be scanned here. const discoveredValues: unknown[] = [rootObj]; // discoveredValues.push = (...value: unknown[]) => { - // if (isSignal(value[0])) { - // debugger; - // } // Array.prototype.push.apply(discoveredValues, value); // }; // let count = 100; @@ -648,7 +653,7 @@ export const createSerializationContext = ( const isRoot = obj === rootObj; // For root objects we pretend we have not seen them to force scan. const id = $wasSeen$(obj); - const unwrapObj = unwrapProxy(obj); + const unwrapObj = unwrapStore2(obj); if (unwrapObj !== obj) { discoveredValues.push(unwrapObj); } else if (id === undefined || isRoot) { @@ -803,12 +808,13 @@ function serialize(serializationContext: SerializationContext): void { } }; - const writeObjectValue = (value: unknown, idx: number) => { + const writeObjectValue = (value: {}, idx: number) => { // Objects are the only way to create circular dependencies. // So the first thing to to is to see if we have a circular dependency. // (NOTE: For root objects we need to serialize them regardless if we have seen // them before, otherwise the root object reference will point to itself.) const seen = depth <= 1 ? undefined : serializationContext.$wasSeen$(value); + let storeHandler: null | StoreHandler = null; if (fastSkipSerialize(value as object)) { writeString(SerializationConstant.UNDEFINED_CHAR); } else if (typeof seen === 'number' && seen >= 0) { @@ -820,9 +826,18 @@ function serialize(serializationContext: SerializationContext): void { const varId = $addRoot$(varProps); const constProps = value[_CONST_PROPS]; const constId = $addRoot$(constProps); - writeString(SerializationConstant.PropsProxy_CHAR + varId + ' ' + constId); - } else if (isStore(value)) { - writeString(SerializationConstant.Store_CHAR + $addRoot$(unwrapProxy(value))); + writeString(SerializationConstant.PropsProxy_CHAR + varId + '|' + constId); + } else if ((storeHandler = getStoreHandler2(value))) { + let store = SerializationConstant.Store_CHAR + $addRoot$(storeHandler.$target$) + ' ' + storeHandler.$flags$; + const effects = storeHandler.$effects$; + if (effects) { + let sep = ' '; + for (const propName in effects) { + store += sep + propName + serializeEffectSubs($addRoot$, effects[propName]) + sep = '|'; + } + } + writeString(store); } else if (isObjectLiteral(value)) { if (isResource(value)) { serializationContext.$resources$.add(value); @@ -931,7 +946,7 @@ function serialize(serializationContext: SerializationContext): void { const out = btoa(buf).replace(/=+$/, ''); writeString(SerializationConstant.Uint8Array_CHAR + out); } else { - throw new Error('implement: ' + JSON.stringify(value)); + throw new Error('implement'); } }; @@ -1088,13 +1103,21 @@ function deserializeSignal2( computedSignal.$computeQrl$ = parseQRL(parts[idx++]) as fixMeAny; } signal.$untrackedValue$ = container.$getObjectById$(parts[idx++]); + if (idx < parts.length) { + const effects = signal.$effects$ || (signal.$effects$ = []); + idx = deserializeSignal2Effect(idx, parts, container, effects); + } +} + +function deserializeSignal2Effect(idx: number, parts: string[], container: DomContainer, effects: EffectSubscriptions[]) { while (idx < parts.length) { // idx == 1 is the attribute name const effect = parts[idx++] .split(' ') .map((obj, idx) => (idx == 1 ? obj : container.$getObjectById$(obj))); - (signal.$effects$ || (signal.$effects$ = [])).push(effect as fixMeAny); + effects.push(effect as fixMeAny); } + return idx; } function setSerializableDataRootId($addRoot$: (value: any) => number, obj: object, value: any) { diff --git a/packages/qwik/src/core/v2/signal/v2-store.ts b/packages/qwik/src/core/v2/signal/v2-store.ts index 01dd37d58f9..50c193ce7e6 100644 --- a/packages/qwik/src/core/v2/signal/v2-store.ts +++ b/packages/qwik/src/core/v2/signal/v2-store.ts @@ -31,15 +31,20 @@ export type Store2 = T & { __BRAND__: 'Store'; }; -let _lastTarget: undefined | StoreHandler; +let _lastHandler: undefined | StoreHandler; -export const getStoreTarget2 = (value: T): T | null => { - _lastTarget = undefined as any; - return typeof value === 'object' && value && STORE in value // this implicitly sets the `_lastTarget` as a side effect. - ? (_lastTarget!.$target$ as T) +export const getStoreHandler2 = (value: T): StoreHandler | null => { + _lastHandler = undefined as any; + return typeof value === 'object' && value && STORE in value // this implicitly sets the `_lastHandler` as a side effect. + ? (_lastHandler!) : null; }; +export const getStoreTarget2 = (value: T): T | null => { + const handler = getStoreHandler2(value); + return handler ? handler.$target$ : null; +}; + export const unwrapStore2 = (value: T): T => { return (getStoreTarget2(value as fixMeAny) as T) || value; }; @@ -48,6 +53,13 @@ export const isStore2 = (value: T): value is Store2 => { return value instanceof Store; }; +export function createStore2(container: Container2 | null | undefined, obj: T & Record, flags: Store2Flags) { + return new Proxy( + new Store(), + new StoreHandler(obj, flags, container || null) + ) as Store2; +} + export const getOrCreateStore2 = ( obj: T, flags: Store2Flags, @@ -56,10 +68,7 @@ export const getOrCreateStore2 = ( if (isSerializableObject(obj)) { let store: Store2 | undefined = storeWeakMap.get(obj) as Store2 | undefined; if (!store) { - store = new Proxy( - new Store(), - new StoreHandler(obj, flags, container || null) - ) as Store2; + store = createStore2(container, obj, flags); storeWeakMap.set(obj, store as any); } return store as Store2; @@ -75,7 +84,7 @@ class Store { export const Store2 = Store; -class StoreHandler> implements ProxyHandler { +export class StoreHandler> implements ProxyHandler { $effects$: null | Record = null; constructor( public $target$: T, @@ -138,7 +147,7 @@ class StoreHandler> implements ProxyHandl // But when effect is scheduled in needs to be able to know which signals // to unsubscribe from. So we need to store the reference from the effect back // to this signal. - ensureContains(effectSubscriber, this); + ensureContains(effectSubscriber, this.$target$); DEBUG && log('read->sub', pad('\n' + this.toString(), ' ')); } } @@ -173,7 +182,7 @@ class StoreHandler> implements ProxyHandl has(_: T, p: string | symbol) { if (p === STORE) { - _lastTarget = this; + _lastHandler = this; return true; } return Object.prototype.hasOwnProperty.call(this.$target$, p); diff --git a/packages/qwik/src/core/v2/tests/use-store.spec.tsx b/packages/qwik/src/core/v2/tests/use-store.spec.tsx index fc71330d207..faa259eedfa 100644 --- a/packages/qwik/src/core/v2/tests/use-store.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-store.spec.tsx @@ -13,8 +13,8 @@ const debug = true; //true; Error.stackTraceLimit = 100; describe.each([ - // { render: ssrRenderToDom }, // - { render: domRender }, // + { render: ssrRenderToDom }, // + // { render: domRender }, // ])('$render.name: useStore', ({ render }) => { it('should render value', async () => { const Cmp = component$(() => { @@ -60,7 +60,7 @@ describe.each([ ); }); - it('should update deep value', async () => { + it.only('should update deep value', async () => { const Counter = component$(() => { const count = useStore({ obj: { count: 123 } }); return ; From 227d9ddff27a8e0fd79a7ccff4fdff8e18f64a75 Mon Sep 17 00:00:00 2001 From: Varixo Date: Tue, 6 Aug 2024 19:17:37 +0200 Subject: [PATCH 25/89] fix store impl --- packages/qwik/src/core/debug.ts | 33 ++++++++++--------- packages/qwik/src/core/use/use-signal.ts | 13 ++++---- packages/qwik/src/core/use/use-task.ts | 2 -- packages/qwik/src/core/v2/shared/scheduler.ts | 2 +- .../core/v2/shared/shared-serialization.ts | 33 ++++++++++++------- packages/qwik/src/core/v2/signal/v2-signal.ts | 2 -- packages/qwik/src/core/v2/signal/v2-store.ts | 26 ++++++++------- .../qwik/src/core/v2/tests/use-store.spec.tsx | 21 +++++------- 8 files changed, 69 insertions(+), 63 deletions(-) diff --git a/packages/qwik/src/core/debug.ts b/packages/qwik/src/core/debug.ts index ecb2f575a14..949060467b8 100644 --- a/packages/qwik/src/core/debug.ts +++ b/packages/qwik/src/core/debug.ts @@ -1,9 +1,9 @@ -import { isQrl } from "../server/prefetch-strategy"; -import { isJSXNode } from "./render/jsx/jsx-runtime"; -import { isTask } from "./use/use-task"; -import { vnode_isVNode, vnode_toString } from "./v2/client/vnode"; -import { isSignal2 } from "./v2/signal/v2-signal"; -import { isStore2 } from "./v2/signal/v2-store"; +import { isQrl } from '../server/prefetch-strategy'; +import { isJSXNode } from './render/jsx/jsx-runtime'; +import { isTask } from './use/use-task'; +import { vnode_isVNode, vnode_toString } from './v2/client/vnode'; +import { isSignal2 } from './v2/signal/v2-signal'; +import { isStore2 } from './v2/signal/v2-store'; const stringifyPath: any[] = []; export function qwikDebugToString(value: any): any { @@ -15,12 +15,16 @@ export function qwikDebugToString(value: any): any { return '"' + value + '"'; } else if (typeof value === 'number' || typeof value === 'boolean') { return String(value); + } else if (isTask(value)) { + return `Task(${qwikDebugToString(value.$qrl$)})`; + } else if (isQrl(value)) { + return `Qrl(${value.$symbol$})`; } else if (typeof value === 'object' || typeof value === 'function') { if (stringifyPath.includes(value)) { return '*'; } if (stringifyPath.length > 10) { - debugger; + // debugger; } try { stringifyPath.push(value); @@ -34,10 +38,6 @@ export function qwikDebugToString(value: any): any { return value.toString(); } else if (isJSXNode(value)) { return jsxToString(value); - } else if (isTask(value)) { - return `Task(${qwikDebugToString(value.$qrl$)})` - } else if (isQrl(value)) { - return `Qrl(${value.$symbol$})` } } finally { stringifyPath.pop(); @@ -46,11 +46,12 @@ export function qwikDebugToString(value: any): any { return value; } - - export const pad = (text: string, prefix: string) => { - return String(text).split('\n').map((line, idx) => (idx ? prefix : '') + line).join('\n'); -} + return String(text) + .split('\n') + .map((line, idx) => (idx ? prefix : '') + line) + .join('\n'); +}; export const jsxToString = (value: any): string => { if (isJSXNode(value)) { @@ -82,4 +83,4 @@ export const jsxToString = (value: any): string => { } else { return String(value); } -} \ No newline at end of file +}; diff --git a/packages/qwik/src/core/use/use-signal.ts b/packages/qwik/src/core/use/use-signal.ts index 96735be43d5..3b6cc85e240 100644 --- a/packages/qwik/src/core/use/use-signal.ts +++ b/packages/qwik/src/core/use/use-signal.ts @@ -1,19 +1,18 @@ import { isQwikComponent } from '../component/component.public'; -import { _createSignal, type Signal } from '../state/signal'; import { isFunction } from '../util/types'; -import { createSignal2 } from '../v2/signal/v2-signal.public'; +import { createSignal2, type Signal2 } from '../v2/signal/v2-signal.public'; import { invoke } from './use-core'; import { useSequentialScope } from './use-sequential-scope'; /** @public */ export interface UseSignal { - (): Signal; - (value: T | (() => T)): Signal; + (): Signal2; + (value: T | (() => T)): Signal2; } /** @public */ -export const useSignal: UseSignal = (initialState?: STATE): Signal => { - const { val, set } = useSequentialScope>(); +export const useSignal: UseSignal = (initialState?: STATE): Signal2 => { + const { val, set } = useSequentialScope>(); if (val != null) { return val; } @@ -22,6 +21,6 @@ export const useSignal: UseSignal = (initialState?: STATE): Signal isFunction(initialState) && !isQwikComponent(initialState) ? invoke(undefined, initialState as any) : initialState; - const signal = createSignal2(value); + const signal = createSignal2(value); return set(signal); }; diff --git a/packages/qwik/src/core/use/use-task.ts b/packages/qwik/src/core/use/use-task.ts index 66f64c9a836..1079199d493 100644 --- a/packages/qwik/src/core/use/use-task.ts +++ b/packages/qwik/src/core/use/use-task.ts @@ -465,7 +465,6 @@ export const useComputedQrl: ComputedQRL = (qrl: QRL>): Signal< return signal; }; - // // !!DO NOT EDIT THIS COMMENT DIRECTLY!!! // (edit ../readme.md#useVisibleTask instead) @@ -531,7 +530,6 @@ export const useVisibleTaskQrl = (qrl: QRL, opts?: OnVisibleTaskOptions) } }; - export type TaskDescriptor = DescriptorBase; export interface ResourceDescriptor diff --git a/packages/qwik/src/core/v2/shared/scheduler.ts b/packages/qwik/src/core/v2/shared/scheduler.ts index af4ff87d204..1b67babb7e3 100644 --- a/packages/qwik/src/core/v2/shared/scheduler.ts +++ b/packages/qwik/src/core/v2/shared/scheduler.ts @@ -103,7 +103,7 @@ import { EffectSubscriptionsProp, isSignal2, type EffectSubscriptions } from '.. import { serializeAttribute } from '../../render/execute-component'; // Turn this on to get debug output of what the scheduler is doing. -const DEBUG: boolean = true; +const DEBUG: boolean = false; export const enum ChoreType { /// MASKS defining three levels of sorting diff --git a/packages/qwik/src/core/v2/shared/shared-serialization.ts b/packages/qwik/src/core/v2/shared/shared-serialization.ts index ca6401faec9..a001a00d399 100644 --- a/packages/qwik/src/core/v2/shared/shared-serialization.ts +++ b/packages/qwik/src/core/v2/shared/shared-serialization.ts @@ -21,11 +21,7 @@ import { isPropsProxy, } from '../../render/jsx/jsx-runtime'; import { Slot } from '../../render/jsx/slot.public'; -import { - fastSkipSerialize, - getProxyFlags, - getSubscriptionManager -} from '../../state/common'; +import { fastSkipSerialize, getProxyFlags, getSubscriptionManager } from '../../state/common'; import { _CONST_PROPS, _VAR_PROPS } from '../../state/constants'; import { Task, isTask, type ResourceReturnInternal } from '../../use/use-task'; import { EMPTY_OBJ } from '../../util/flyweight'; @@ -42,7 +38,13 @@ import { Signal2, type EffectSubscriptions, } from '../signal/v2-signal'; -import { Store2, createStore2, getStoreHandler2, unwrapStore2, type StoreHandler } from '../signal/v2-store'; +import { + Store2, + createStore2, + getStoreHandler2, + unwrapStore2, + type StoreHandler, +} from '../signal/v2-store'; import type { SymbolToChunkResolver } from '../ssr/ssr-types'; import type { fixMeAny } from './types'; @@ -277,7 +279,7 @@ const inflate = (container: DomContainer, target: any, needsInflationData: strin const idx = effect.indexOf(';'); const prop = effect.substring(0, idx); const effectStr = effect.substring(idx + 1); - deserializeSignal2Effect(0, effectStr.split(';'), container, effects[prop] = []) + deserializeSignal2Effect(0, effectStr.split(';'), container, (effects[prop] = [])); } } break; @@ -826,14 +828,18 @@ function serialize(serializationContext: SerializationContext): void { const varId = $addRoot$(varProps); const constProps = value[_CONST_PROPS]; const constId = $addRoot$(constProps); - writeString(SerializationConstant.PropsProxy_CHAR + varId + '|' + constId); + writeString(SerializationConstant.PropsProxy_CHAR + varId + ' ' + constId); } else if ((storeHandler = getStoreHandler2(value))) { - let store = SerializationConstant.Store_CHAR + $addRoot$(storeHandler.$target$) + ' ' + storeHandler.$flags$; + let store = + SerializationConstant.Store_CHAR + + $addRoot$(storeHandler.$target$) + + ' ' + + storeHandler.$flags$; const effects = storeHandler.$effects$; if (effects) { let sep = ' '; for (const propName in effects) { - store += sep + propName + serializeEffectSubs($addRoot$, effects[propName]) + store += sep + propName + serializeEffectSubs($addRoot$, effects[propName]); sep = '|'; } } @@ -1109,7 +1115,12 @@ function deserializeSignal2( } } -function deserializeSignal2Effect(idx: number, parts: string[], container: DomContainer, effects: EffectSubscriptions[]) { +function deserializeSignal2Effect( + idx: number, + parts: string[], + container: DomContainer, + effects: EffectSubscriptions[] +) { while (idx < parts.length) { // idx == 1 is the attribute name const effect = parts[idx++] diff --git a/packages/qwik/src/core/v2/signal/v2-signal.ts b/packages/qwik/src/core/v2/signal/v2-signal.ts index 4c56d17ea3f..c3ba641efc1 100644 --- a/packages/qwik/src/core/v2/signal/v2-signal.ts +++ b/packages/qwik/src/core/v2/signal/v2-signal.ts @@ -227,8 +227,6 @@ export const ensureContainsEffect = (array: EffectSubscriptions[], effect: Effec return; } } - console.log('array', array); - console.log('array.push', array.push); array.push(effect); }; diff --git a/packages/qwik/src/core/v2/signal/v2-store.ts b/packages/qwik/src/core/v2/signal/v2-store.ts index 50c193ce7e6..e4696d777c6 100644 --- a/packages/qwik/src/core/v2/signal/v2-store.ts +++ b/packages/qwik/src/core/v2/signal/v2-store.ts @@ -1,7 +1,7 @@ import { pad, qwikDebugToString } from '../../debug'; import { assertDefined, assertTrue } from '../../error/assert'; import { tryGetInvokeContext } from '../../use/use-core'; -import { isObject, isSerializableObject } from '../../util/types'; +import { isSerializableObject } from '../../util/types'; import type { VNode } from '../client/types'; import type { Container2, fixMeAny } from '../shared/types'; import { @@ -12,7 +12,7 @@ import { type EffectSubscriptions, } from './v2-signal'; -const DEBUG = true; +const DEBUG = false; // eslint-disable-next-line no-console const log = (...args: any[]) => console.log('STORE', ...args.map(qwikDebugToString)); @@ -36,7 +36,7 @@ let _lastHandler: undefined | StoreHandler; export const getStoreHandler2 = (value: T): StoreHandler | null => { _lastHandler = undefined as any; return typeof value === 'object' && value && STORE in value // this implicitly sets the `_lastHandler` as a side effect. - ? (_lastHandler!) + ? _lastHandler! : null; }; @@ -53,11 +53,12 @@ export const isStore2 = (value: T): value is Store2 => { return value instanceof Store; }; -export function createStore2(container: Container2 | null | undefined, obj: T & Record, flags: Store2Flags) { - return new Proxy( - new Store(), - new StoreHandler(obj, flags, container || null) - ) as Store2; +export function createStore2( + container: Container2 | null | undefined, + obj: T & Record, + flags: Store2Flags +) { + return new Proxy(new Store(), new StoreHandler(obj, flags, container || null)) as Store2; } export const getOrCreateStore2 = ( @@ -69,11 +70,11 @@ export const getOrCreateStore2 = ( let store: Store2 | undefined = storeWeakMap.get(obj) as Store2 | undefined; if (!store) { store = createStore2(container, obj, flags); - storeWeakMap.set(obj, store as any); + storeWeakMap.set(obj, store); } return store as Store2; } - return obj as any; + return obj as Store2; }; class Store { @@ -90,7 +91,7 @@ export class StoreHandler> implements Pro public $target$: T, public $flags$: Store2Flags, public $container$: Container2 | null - ) { } + ) {} toString() { const flags = []; @@ -158,6 +159,7 @@ export class StoreHandler> implements Pro const flags = this.$flags$; if (flags & Store2Flags.RECURSIVE && typeof value === 'object' && value !== null) { value = getOrCreateStore2(value, this.$flags$, this.$container$); + (target as Record)[p] = value; } return value; } @@ -168,7 +170,7 @@ export class StoreHandler> implements Pro if (value !== oldValue) { DEBUG && log('Signal.set', oldValue, '->', value, pad('\n' + this.toString(), ' ')); (target as any)[p] = value; - triggerEffects(this.$container$, this, this.$effects$?.[String(p)]); + triggerEffects(this.$container$, this, this.$effects$ ? this.$effects$[String(p)] : null); } return true; } diff --git a/packages/qwik/src/core/v2/tests/use-store.spec.tsx b/packages/qwik/src/core/v2/tests/use-store.spec.tsx index faa259eedfa..7623b9f7082 100644 --- a/packages/qwik/src/core/v2/tests/use-store.spec.tsx +++ b/packages/qwik/src/core/v2/tests/use-store.spec.tsx @@ -9,12 +9,12 @@ import { untrack } from '../../use/use-core'; import { useSignal } from '../../use/use-signal'; import { useStore } from '../../use/use-store.public'; -const debug = true; //true; +const debug = false; //true; Error.stackTraceLimit = 100; describe.each([ { render: ssrRenderToDom }, // - // { render: domRender }, // + { render: domRender }, // ])('$render.name: useStore', ({ render }) => { it('should render value', async () => { const Cmp = component$(() => { @@ -60,7 +60,7 @@ describe.each([ ); }); - it.only('should update deep value', async () => { + it('should update deep value', async () => { const Counter = component$(() => { const count = useStore({ obj: { count: 123 } }); return ; @@ -189,9 +189,7 @@ describe.each([ const count = useStore({ jsx: 'initial' }); log.push('Counter: ' + untrack(() => count.jsx)); return ( - ); @@ -460,8 +458,7 @@ describe.each([ ); }); - // TODO(optimizer-test): this is failing also in v1 - it.skip('#5017 - should update child nodes for direct array', async () => { + it('#5017 - should update child nodes for direct array', async () => { const Child = component$<{ columns: string }>(({ columns }) => { return
Child: {columns}
; }); @@ -488,13 +485,13 @@ describe.each([
{'Child: '} - {'INITIAL'} + {'INITIAL'}
{'Child: '} - {'INITIAL'} + {'INITIAL'}
@@ -508,13 +505,13 @@ describe.each([
{'Child: '} - {'UPDATE'} + {'UPDATE'}
{'Child: '} - {'UPDATE'} + {'UPDATE'}
From 087872e41739f9f89b651bd168819638e891f0f7 Mon Sep 17 00:00:00 2001 From: Varixo Date: Wed, 7 Aug 2024 16:28:52 +0200 Subject: [PATCH 26/89] fix useComputed$ --- packages/docs/src/routes/api/qwik/api.json | 103 +++++++- packages/docs/src/routes/api/qwik/index.md | 241 +++++++++++++++++- packages/qwik/src/core/api.md | 41 ++- packages/qwik/src/core/index.ts | 1 + .../qwik/src/core/render/jsx/jsx-runtime.ts | 143 +---------- packages/qwik/src/core/use/use-task-dollar.ts | 4 +- packages/qwik/src/core/use/use-task.ts | 39 +-- .../core/v2/shared/shared-serialization.ts | 2 +- .../src/core/v2/signal/v2-signal.public.ts | 7 +- packages/qwik/src/core/v2/signal/v2-signal.ts | 3 +- .../src/core/v2/tests/use-computed.spec.tsx | 31 ++- .../src/core/v2/tests/use-context.spec.tsx | 4 +- .../qwik/src/core/v2/tests/use-task.spec.tsx | 30 ++- 13 files changed, 445 insertions(+), 204 deletions(-) diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 22ede9ec353..d35b9d133f7 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -506,6 +506,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/component/component.public.ts", "mdFile": "qwik.componentqrl.md" }, + { + "name": "ComputedSignal2", + "id": "computedsignal2", + "hierarchy": [ + { + "name": "ComputedSignal2", + "id": "computedsignal2" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface ComputedSignal2 extends ReadonlySignal2 \n```\n**Extends:** [ReadonlySignal2](#readonlysignal2)<T>\n\n\n\n\n
\n\nMethod\n\n\n\n\nDescription\n\n\n
\n\n[force()](#computedsignal2-force)\n\n\n\n\nUse this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object. Useful for third-party libraries.\n\n\n
", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts", + "mdFile": "qwik.computedsignal2.md" + }, { "name": "ContextId", "id": "contextid", @@ -548,6 +562,34 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/render/jsx/types/jsx-qwik-attributes.ts", "mdFile": "qwik.correctedtoggleevent.md" }, + { + "name": "createComputed2$", + "id": "createcomputed2_", + "hierarchy": [ + { + "name": "createComputed2$", + "id": "createcomputed2_" + } + ], + "kind": "Function", + "content": "```typescript\ncreateComputed2$: (first: () => T) => ComputedSignal2\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfirst\n\n\n\n\n() => T\n\n\n\n\n\n
\n**Returns:**\n\n[ComputedSignal2](#computedsignal2)<T>", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts", + "mdFile": "qwik.createcomputed2_.md" + }, + { + "name": "createComputed2Qrl", + "id": "createcomputed2qrl", + "hierarchy": [ + { + "name": "createComputed2Qrl", + "id": "createcomputed2qrl" + } + ], + "kind": "Function", + "content": "```typescript\ncreateComputed2Qrl: (qrl: QRL<() => T>) => ComputedSignal2\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nqrl\n\n\n\n\n[QRL](#qrl)<() => T>\n\n\n\n\n\n
\n**Returns:**\n\n[ComputedSignal2](#computedsignal2)<T>", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts", + "mdFile": "qwik.createcomputed2qrl.md" + }, { "name": "createContextId", "id": "createcontextid", @@ -562,6 +604,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-context.ts", "mdFile": "qwik.createcontextid.md" }, + { + "name": "createSignal2", + "id": "createsignal2", + "hierarchy": [ + { + "name": "createSignal2", + "id": "createsignal2" + } + ], + "kind": "Variable", + "content": "```typescript\ncreateSignal2: {\n (): Signal2;\n (value: T): Signal2;\n}\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts", + "mdFile": "qwik.createsignal2.md" + }, { "name": "CSSProperties", "id": "cssproperties", @@ -809,6 +865,23 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/render/jsx/types/jsx-generated.ts", "mdFile": "qwik.fieldsethtmlattributes.md" }, + { + "name": "force", + "id": "computedsignal2-force", + "hierarchy": [ + { + "name": "ComputedSignal2", + "id": "computedsignal2-force" + }, + { + "name": "force", + "id": "computedsignal2-force" + } + ], + "kind": "MethodSignature", + "content": "Use this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object. Useful for third-party libraries.\n\n\n```typescript\nforce(): void;\n```\n**Returns:**\n\nvoid", + "mdFile": "qwik.computedsignal2.force.md" + }, { "name": "FormHTMLAttributes", "id": "formhtmlattributes", @@ -1130,7 +1203,7 @@ } ], "kind": "Function", - "content": "```typescript\nisSignal2: (value: any) => value is ISignal2\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nvalue\n\n\n\n\nany\n\n\n\n\n\n
\n**Returns:**\n\nvalue is ISignal2<unknown>", + "content": "```typescript\nisSignal2: (value: any) => value is ISignal2\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nvalue\n\n\n\n\nany\n\n\n\n\n\n
\n**Returns:**\n\nvalue is [ISignal2](#signal2)<unknown>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.ts", "mdFile": "qwik.issignal.md" }, @@ -2212,6 +2285,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/state/signal.ts", "mdFile": "qwik.readonlysignal.md" }, + { + "name": "ReadonlySignal2", + "id": "readonlysignal2", + "hierarchy": [ + { + "name": "ReadonlySignal2", + "id": "readonlysignal2" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface ReadonlySignal2 \n```\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[untrackedValue](#)\n\n\n\n\n`readonly`\n\n\n\n\nT\n\n\n\n\n\n
\n\n[value](#)\n\n\n\n\n`readonly`\n\n\n\n\nT\n\n\n\n\n\n
", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts", + "mdFile": "qwik.readonlysignal2.md" + }, { "name": "render", "id": "render", @@ -2464,6 +2551,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/state/signal.ts", "mdFile": "qwik.signal.md" }, + { + "name": "Signal2", + "id": "signal2", + "hierarchy": [ + { + "name": "Signal2", + "id": "signal2" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface Signal2 extends ReadonlySignal2 \n```\n**Extends:** [ReadonlySignal2](#readonlysignal2)<T>\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[untrackedValue](#)\n\n\n\n\n\n\n\nT\n\n\n\n\n\n
\n\n[value](#)\n\n\n\n\n\n\n\nT\n\n\n\n\n\n
", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts", + "mdFile": "qwik.signal2.md" + }, { "name": "Size", "id": "size", diff --git a/packages/docs/src/routes/api/qwik/index.md b/packages/docs/src/routes/api/qwik/index.md index e74d7eca504..e72df1606f8 100644 --- a/packages/docs/src/routes/api/qwik/index.md +++ b/packages/docs/src/routes/api/qwik/index.md @@ -1408,6 +1408,36 @@ componentQrl [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/component/component.public.ts) +## ComputedSignal2 + +```typescript +export interface ComputedSignal2 extends ReadonlySignal2 +``` + +**Extends:** [ReadonlySignal2](#readonlysignal2)<T> + + + +
+ +Method + + + +Description + +
+ +[force()](#computedsignal2-force) + + + +Use this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object. Useful for third-party libraries. + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts) + ## ContextId ContextId is a typesafe ID for your context. @@ -1688,6 +1718,80 @@ Description [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/render/jsx/types/jsx-qwik-attributes.ts) +## createComputed2$ + +```typescript +createComputed2$: (first: () => T) => ComputedSignal2; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +first + + + +() => T + + + +
+**Returns:** + +[ComputedSignal2](#computedsignal2)<T> + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts) + +## createComputed2Qrl + +```typescript +createComputed2Qrl: (qrl: QRL<() => T>) => ComputedSignal2; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +qrl + + + +[QRL](#qrl)<() => T> + + + +
+**Returns:** + +[ComputedSignal2](#computedsignal2)<T> + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts) + ## createContextId Create a context ID to be used in your application. The name should be written with no spaces. @@ -1769,6 +1873,17 @@ The name of the context. [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-context.ts) +## createSignal2 + +```typescript +createSignal2: { + (): Signal2; + (value: T): Signal2; +} +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts) + ## CSSProperties ```typescript @@ -2154,6 +2269,18 @@ export interface FieldsetHTMLAttributes extends Attrs<'fields [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/render/jsx/types/jsx-generated.ts) +## force + +Use this to force recalculation and running subscribers, for example when the calculated value mutates but remains the same object. Useful for third-party libraries. + +```typescript +force(): void; +``` + +**Returns:** + +void + ## FormHTMLAttributes ```typescript @@ -2644,7 +2771,7 @@ any
**Returns:** -value is ISignal2<unknown> +value is [ISignal2](#signal2)<unknown> [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.ts) @@ -4368,6 +4495,63 @@ export type ReadonlySignal = Readonly>; [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/state/signal.ts) +## ReadonlySignal2 + +```typescript +export interface ReadonlySignal2 +``` + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +[untrackedValue](#) + + + +`readonly` + + + +T + + + +
+ +[value](#) + + + +`readonly` + + + +T + + + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts) + ## render Render JSX. @@ -5245,6 +5429,61 @@ T [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/state/signal.ts) +## Signal2 + +```typescript +export interface Signal2 extends ReadonlySignal2 +``` + +**Extends:** [ReadonlySignal2](#readonlysignal2)<T> + + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +[untrackedValue](#) + + + + + +T + + + +
+ +[value](#) + + + + + +T + + + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/v2/signal/v2-signal.public.ts) + ## Size ```typescript diff --git a/packages/qwik/src/core/api.md b/packages/qwik/src/core/api.md index e8df737089e..210aa847f82 100644 --- a/packages/qwik/src/core/api.md +++ b/packages/qwik/src/core/api.md @@ -162,6 +162,11 @@ export interface ComponentBaseProps { // @public export const componentQrl: >(componentQrl: QRL>) => Component; +// @public (undocumented) +export interface ComputedSignal2 extends ReadonlySignal2 { + force(): void; +} + // @internal (undocumented) export const _CONST_PROPS: unique symbol; @@ -197,9 +202,21 @@ export interface CorrectedToggleEvent extends Event { readonly prevState: 'open' | 'closed'; } +// @public (undocumented) +export const createComputed2$: (first: () => T) => ComputedSignal2; + +// @public (undocumented) +export const createComputed2Qrl: (qrl: QRL<() => T>) => ComputedSignal2; + // @public export const createContextId: (name: string) => ContextId; +// @public (undocumented) +export const createSignal2: { + (): Signal2; + (value: T): Signal2; +}; + // @public (undocumented) export interface CSSProperties extends CSS_2.Properties, CSS_2.PropertiesHyphen { [v: `--${string}`]: string | number | undefined; @@ -507,10 +524,8 @@ export type IntrinsicSVGElements = { // @internal (undocumented) export const _isJSXNode: (n: unknown) => n is JSXNode; -// Warning: (ae-forgotten-export) The symbol "Signal2_2" needs to be exported by the entry point index.d.ts -// // @public (undocumented) -export const isSignal: (value: any) => value is Signal2_2; +export const isSignal: (value: any) => value is Signal2; // @internal (undocumented) export function _isStringifiable(value: unknown): value is _Stringifiable; @@ -916,6 +931,14 @@ export type QwikWheelEvent = NativeWheelEvent; // @public (undocumented) export type ReadonlySignal = Readonly>; +// @public (undocumented) +export interface ReadonlySignal2 { + // (undocumented) + readonly untrackedValue: T; + // (undocumented) + readonly value: T; +} + // @internal (undocumented) export const _regSymbol: (symbol: any, hash: string) => any; @@ -1101,6 +1124,14 @@ export interface Signal { value: T; } +// @public (undocumented) +export interface Signal2 extends ReadonlySignal2 { + // (undocumented) + untrackedValue: T; + // (undocumented) + value: T; +} + // @public (undocumented) export type Size = number | string; @@ -1893,9 +1924,9 @@ export function useServerData(key: string, defaultValue: B): T | B; // @public (undocumented) export interface UseSignal { // (undocumented) - (): Signal; + (): Signal2; // (undocumented) - (value: T | (() => T)): Signal; + (value: T | (() => T)): Signal2; } // @public (undocumented) diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index 6f581590f3f..1315c768ade 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -184,3 +184,4 @@ export { PrefetchServiceWorker, PrefetchGraph } from './components/prefetch'; // INTERNAL ////////////////////////////////////////////////////////////////////////////////////////// export * from './internal'; +export * from './v2/signal/v2-signal.public'; diff --git a/packages/qwik/src/core/render/jsx/jsx-runtime.ts b/packages/qwik/src/core/render/jsx/jsx-runtime.ts index ec67e96e4e8..4d606838508 100644 --- a/packages/qwik/src/core/render/jsx/jsx-runtime.ts +++ b/packages/qwik/src/core/render/jsx/jsx-runtime.ts @@ -1,24 +1,22 @@ -import { isBrowser } from '@builder.io/qwik/build'; import type { JsxChild } from 'typescript'; -import { isQwikComponent, type OnRenderFn } from '../../component/component.public'; +import { type OnRenderFn } from '../../component/component.public'; import { _CONST_PROPS } from '../../internal'; -import { isQrl, type QRLInternal } from '../../qrl/qrl-class'; -import { verifySerializable } from '../../state/common'; +import { type QRLInternal } from '../../qrl/qrl-class'; import { _VAR_PROPS } from '../../state/constants'; import { isSignal, SignalDerived } from '../../state/signal'; -import { invoke, untrack } from '../../use/use-core'; +import { untrack } from '../../use/use-core'; import { EMPTY_OBJ } from '../../util/flyweight'; -import { logError, logOnceWarn, logWarn } from '../../util/log'; +import { logOnceWarn, logWarn } from '../../util/log'; import { ELEMENT_ID, OnRenderProp, QScopedStyle, QSlot, QSlotS } from '../../util/markers'; import { isPromise } from '../../util/promises'; -import { qDev, qRuntimeQrl, seal } from '../../util/qdev'; -import { isArray, isFunction, isObject, isString } from '../../util/types'; +import { qDev, seal } from '../../util/qdev'; +import { isArray, isObject, isString } from '../../util/types'; +import { isSignal2 } from '../../v2/signal/v2-signal'; import { static_subtree } from '../execute-component'; import type { DevJSX, FunctionComponent, JSXNode } from './types/jsx-node'; import type { QwikJSX } from './types/jsx-qwik'; import type { JSXChildren } from './types/jsx-qwik-attributes'; import { SkipRender } from './utils.public'; -import { isSignal2 } from '../../v2/signal/v2-signal'; export type Props = Record; @@ -62,7 +60,6 @@ export const _jsxSorted = ( ...dev, }; } - validateJSXNode(node); seal(node); return node; }; @@ -222,131 +219,6 @@ export const RenderOnce: FunctionComponent<{ return new JSXNodeImpl(Virtual, EMPTY_OBJ, null, props.children, static_subtree, key); }; -const validateJSXNode = (node: JSXNode) => { - if (qDev) { - const { type, varProps, constProps, children } = node; - invoke(undefined, () => { - const isQwikC = isQwikComponent(type); - if (!isString(type) && !isFunction(type)) { - throw new Error( - `The of the JSX element must be either a string or a function. Instead, it's a "${typeof type}": ${String( - type - )}.` - ); - } - if (children) { - const flatChildren = isArray(children) ? children.flat() : [children]; - if (isString(type) || isQwikC) { - flatChildren.forEach((child: unknown) => { - if (!isValidJSXChild(child)) { - const typeObj = typeof child; - let explanation = ''; - if (typeObj === 'object') { - if (child?.constructor) { - explanation = `it's an instance of "${child?.constructor.name}".`; - } else { - explanation = `it's a object literal: ${printObjectLiteral(child as {})} `; - } - } else if (typeObj === 'function') { - explanation += `it's a function named "${(child as Function).name}".`; - } else { - explanation = `it's a "${typeObj}": ${String(child)}.`; - } - - throw new Error( - `One of the children of <${type}> is not an accepted value. JSX children must be either: string, boolean, number, , Array, undefined/null, or a Promise/Signal. Instead, ${explanation}\n` - ); - } - }); - } - if (isBrowser) { - if (isFunction(type) || constProps) { - const keys: Record = {}; - flatChildren.forEach((child: unknown) => { - if (isJSXNode(child) && child.key != null) { - const key = String(child.type) + ':' + child.key; - if (keys[key]) { - const err = createJSXError( - `Multiple JSX sibling nodes with the same key.\nThis is likely caused by missing a custom key in a for loop`, - child - ); - if (err) { - if (isString(child.type)) { - logOnceWarn(err); - } else { - logOnceWarn(err); - } - } - } else { - keys[key] = true; - } - } - }); - } - } - } - - const allProps = [ - ...(varProps ? Object.entries(varProps) : []), - ...(constProps ? Object.entries(constProps) : []), - ]; - if (!qRuntimeQrl) { - for (const [prop, value] of allProps) { - if (prop.endsWith('$') && value) { - if (!isQrl(value) && !Array.isArray(value)) { - throw new Error( - `The value passed in ${prop}={...}> must be a QRL, instead you passed a "${typeof value}". Make sure your ${typeof value} is wrapped with $(...), so it can be serialized. Like this:\n$(${String( - value - )})` - ); - } - } - if (prop !== 'children' && isQwikC && value) { - verifySerializable( - value, - `The value of the JSX attribute "${prop}" can not be serialized` - ); - } - } - } - if (isString(type)) { - const hasSetInnerHTML = allProps.some((a) => a[0] === 'dangerouslySetInnerHTML'); - if (hasSetInnerHTML && children && (Array.isArray(children) ? children.length > 0 : true)) { - const err = createJSXError( - `The JSX element <${type}> can not have both 'dangerouslySetInnerHTML' and children.`, - node - ); - logError(err); - } - // if (allProps.some((a) => a[0] === 'children')) { - // throw new Error(`The JSX element <${type}> can not have both 'children' as a property.`); - // } - if (type === 'style') { - if (children) { - logOnceWarn(`jsx: Using will escape the content, effectively breaking the CSS. -In order to disable content escaping use '