@preact/signals-react 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,15 +1,3 @@
1
- import {
2
- useRef,
3
- useMemo,
4
- useEffect,
5
- // @ts-ignore-next-line
6
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
7
- __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED as ReactInternals,
8
- type ReactElement,
9
- } from "react";
10
- import React from "react";
11
- import jsxRuntime from "react/jsx-runtime";
12
- import jsxRuntimeDev from "react/jsx-dev-runtime";
13
1
  import {
14
2
  signal,
15
3
  computed,
@@ -18,449 +6,21 @@ import {
18
6
  Signal,
19
7
  type ReadonlySignal,
20
8
  } from "@preact/signals-core";
21
- import { useSyncExternalStore } from "use-sync-external-store/shim/index.js";
22
- import type { Effect, JsxRuntimeModule } from "./internal";
23
-
24
- export { signal, computed, batch, effect, Signal, type ReadonlySignal };
25
-
26
- const Empty = [] as const;
27
- const ReactElemType = Symbol.for("react.element"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15
28
-
29
- interface ReactDispatcher {
30
- useRef: typeof React.useRef;
31
- useCallback: typeof React.useCallback;
32
- useReducer: typeof React.useReducer;
33
- useSyncExternalStore: typeof React.useSyncExternalStore;
34
- useEffect: typeof React.useEffect;
35
- useImperativeHandle: typeof React.useImperativeHandle;
36
- }
37
-
38
- let finishUpdate: (() => void) | undefined;
39
-
40
- function setCurrentUpdater(updater?: Effect) {
41
- // end tracking for the current update:
42
- if (finishUpdate) finishUpdate();
43
- // start tracking the new update:
44
- finishUpdate = updater && updater._start();
45
- }
46
-
47
- interface EffectStore {
48
- updater: Effect;
49
- subscribe(onStoreChange: () => void): () => void;
50
- getSnapshot(): number;
51
- }
52
-
53
- /**
54
- * A redux-like store whose store value is a positive 32bit integer (a 'version').
55
- *
56
- * React subscribes to this store and gets a snapshot of the current 'version',
57
- * whenever the 'version' changes, we tell React it's time to update the component (call 'onStoreChange').
58
- *
59
- * How we achieve this is by creating a binding with an 'effect', when the `effect._callback' is called,
60
- * we update our store version and tell React to re-render the component ([1] We don't really care when/how React does it).
61
- *
62
- * [1]
63
- * @see https://react.dev/reference/react/useSyncExternalStore
64
- * @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
65
- */
66
- function createEffectStore(): EffectStore {
67
- let updater!: Effect;
68
- let version = 0;
69
- let onChangeNotifyReact: (() => void) | undefined;
70
-
71
- let unsubscribe = effect(function (this: Effect) {
72
- updater = this;
73
- });
74
- updater._callback = function () {
75
- version = (version + 1) | 0;
76
- if (onChangeNotifyReact) onChangeNotifyReact();
77
- };
78
-
79
- return {
80
- updater,
81
- subscribe(onStoreChange) {
82
- onChangeNotifyReact = onStoreChange;
83
-
84
- return function () {
85
- /**
86
- * Rotate to next version when unsubscribing to ensure that components are re-run
87
- * when subscribing again.
88
- *
89
- * In StrictMode, 'memo'-ed components seem to keep a stale snapshot version, so
90
- * don't re-run after subscribing again if the version is the same as last time.
91
- *
92
- * Because we unsubscribe from the effect, the version may not change. We simply
93
- * set a new initial version in case of stale snapshots here.
94
- */
95
- version = (version + 1) | 0;
96
- onChangeNotifyReact = undefined;
97
- unsubscribe();
98
- };
99
- },
100
- getSnapshot() {
101
- return version;
102
- },
103
- };
104
- }
105
-
106
- /**
107
- * Custom hook to create the effect to track signals used during render and
108
- * subscribe to changes to rerender the component when the signals change
109
- */
110
- function usePreactSignalStore(nextDispatcher: ReactDispatcher): EffectStore {
111
- const storeRef = nextDispatcher.useRef<EffectStore>();
112
- if (storeRef.current == null) {
113
- storeRef.current = createEffectStore();
114
- }
115
-
116
- const store = storeRef.current;
117
- useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
118
-
119
- return store;
120
- }
121
-
122
- // In order for signals to work in React, we need to observe what signals a
123
- // component uses while rendering. To do this, we need to know when a component
124
- // is rendering. To do this, we watch the transition of the
125
- // ReactCurrentDispatcher to know when a component is rerendering.
126
- //
127
- // To track when we are entering and exiting a component render (i.e. before and
128
- // after React renders a component), we track how the dispatcher changes.
129
- // Outside of a component rendering, the dispatcher is set to an instance that
130
- // errors or warns when any hooks are called. This behavior is prevents hooks
131
- // from being used outside of components. Right before React renders a
132
- // component, the dispatcher is set to an instance that doesn't warn or error
133
- // and contains the implementations of all hooks. Right after React finishes
134
- // rendering a component, the dispatcher is set to the erroring one again. This
135
- // erroring dispatcher is called the `ContextOnlyDispatcher` in React's source.
136
- //
137
- // So, we watch the getter and setter on `ReactCurrentDispatcher.current` to
138
- // monitor the changes to the current ReactDispatcher. When the dispatcher
139
- // changes from the ContextOnlyDispatcher to a "valid" dispatcher, we assume we
140
- // are entering a component render. At this point, we setup our
141
- // auto-subscriptions for any signals used in the component. We do this by
142
- // creating an Signal effect and manually starting the Signal effect. We use
143
- // `useSyncExternalStore` to trigger rerenders on the component when any signals
144
- // it uses changes.
145
- //
146
- // When the dispatcher changes from a valid dispatcher back to the
147
- // ContextOnlyDispatcher, we assume we are exiting a component render. At this
148
- // point we stop the effect.
149
- //
150
- // Some additional complexities to be aware of:
151
- // - If a component calls `setState` while rendering, React will re-render the
152
- // component immediately. Before triggering the re-render, React will change
153
- // the dispatcher to the HooksDispatcherOnRerender. When we transition to this
154
- // rerendering adapter, we need to re-trigger our hooks to keep the order of
155
- // hooks the same for every render of a component.
156
- //
157
- // - In development, useReducer, useState, and useMemo change the dispatcher to
158
- // a different warning dispatcher (not ContextOnlyDispatcher) before invoking
159
- // the reducer and resets it right after.
160
- //
161
- // The useSyncExternalStore shim will use some of these hooks when we invoke
162
- // it while entering a component render. We need to prevent this dispatcher
163
- // change caused by these hooks from re-triggering our entering logic (it
164
- // would cause an infinite loop if we did not). We do this by using a lock to
165
- // prevent the setter from running while we are in the setter.
166
- //
167
- // When a Component's function body invokes useReducer, useState, or useMemo,
168
- // this change in dispatcher should not signal that we are entering or exiting
169
- // a component render. We ignore this change by detecting these dispatchers as
170
- // different from ContextOnlyDispatcher and other valid dispatchers.
171
- //
172
- // - The `use` hook will change the dispatcher to from a valid update dispatcher
173
- // to a valid mount dispatcher in some cases. Similarly to useReducer
174
- // mentioned above, we should not signal that we are exiting a component
175
- // during this change. Because these other valid dispatchers do not pass the
176
- // ContextOnlyDispatcher check, they do not affect our logic.
177
- //
178
- // - When server rendering, React does not change the dispatcher before and
179
- // after each component render. It sets it once for before the first render
180
- // and once for after the last render. This means that we will not be able to
181
- // detect when we are entering or exiting a component render. This is fine
182
- // because we don't need to detect this for server rendering. A component
183
- // can't trigger async rerenders in SSR so we don't need to track signals.
184
- //
185
- // If a component updates a signal value while rendering during SSR, we will
186
- // not rerender the component because the signal value will synchronously
187
- // change so all reads of the signal further down the tree will see the new
188
- // value.
189
-
190
- /*
191
- Below is a state machine definition for transitions between the various
192
- dispatchers in React's prod build. (It does not include dev time warning
193
- dispatchers which are just always ignored).
194
-
195
- ENTER and EXIT suffixes indicates whether this ReactCurrentDispatcher transition
196
- signals we are entering or exiting a component render, or if it doesn't signal a
197
- change in the component rendering lifecyle (NOOP).
198
-
199
- ```js
200
- // Paste this into https://stately.ai/viz to visualize the state machine.
201
- import { createMachine } from "xstate";
202
-
203
- // ENTER, EXIT, NOOP suffixes indicates whether this ReactCurrentDispatcher
204
- // transition signals we are entering or exiting a component render, or
205
- // if it doesn't signal a change in the component rendering lifecyle (NOOP).
206
-
207
- const dispatcherMachinePROD = createMachine({
208
- id: "ReactCurrentDispatcher_PROD",
209
- initial: "null",
210
- states: {
211
- null: {
212
- on: {
213
- pushDispatcher: "ContextOnlyDispatcher",
214
- },
215
- },
216
- ContextOnlyDispatcher: {
217
- on: {
218
- renderWithHooks_Mount_ENTER: "HooksDispatcherOnMount",
219
- renderWithHooks_Update_ENTER: "HooksDispatcherOnUpdate",
220
- pushDispatcher_NOOP: "ContextOnlyDispatcher",
221
- popDispatcher_NOOP: "ContextOnlyDispatcher",
222
- },
223
- },
224
- HooksDispatcherOnMount: {
225
- on: {
226
- renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
227
- resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
228
- finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
229
- },
230
- },
231
- HooksDispatcherOnUpdate: {
232
- on: {
233
- renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
234
- resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
235
- finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
236
- use_ResumeSuspensedMount_NOOP: "HooksDispatcherOnMount",
237
- },
238
- },
239
- HooksDispatcherOnRerender: {
240
- on: {
241
- renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
242
- resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
243
- finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
244
- },
245
- },
246
- },
247
- });
248
- ```
249
- */
250
-
251
- let lock = false;
252
- let currentDispatcher: ReactDispatcher | null = null;
253
- Object.defineProperty(ReactInternals.ReactCurrentDispatcher, "current", {
254
- get() {
255
- return currentDispatcher;
256
- },
257
- set(nextDispatcher: ReactDispatcher) {
258
- if (lock) {
259
- currentDispatcher = nextDispatcher;
260
- return;
261
- }
262
-
263
- const currentDispatcherType = getDispatcherType(currentDispatcher);
264
- const nextDispatcherType = getDispatcherType(nextDispatcher);
265
-
266
- // Update the current dispatcher now so the hooks inside of the
267
- // useSyncExternalStore shim get the right dispatcher.
268
- currentDispatcher = nextDispatcher;
269
- if (isEnteringComponentRender(currentDispatcherType, nextDispatcherType)) {
270
- lock = true;
271
- const store = usePreactSignalStore(nextDispatcher);
272
- lock = false;
273
-
274
- setCurrentUpdater(store.updater);
275
- } else if (
276
- isExitingComponentRender(currentDispatcherType, nextDispatcherType)
277
- ) {
278
- setCurrentUpdater();
279
- }
280
- },
281
- });
282
-
283
- type DispatcherType = number;
284
- const ContextOnlyDispatcherType = 1 << 0;
285
- const WarningDispatcherType = 1 << 1;
286
- const MountDispatcherType = 1 << 2;
287
- const UpdateDispatcherType = 1 << 3;
288
- const RerenderDispatcherType = 1 << 4;
289
- const ServerDispatcherType = 1 << 5;
290
- const BrowserClientDispatcherType =
291
- MountDispatcherType | UpdateDispatcherType | RerenderDispatcherType;
9
+ import type { ReactElement } from "react";
10
+ import { useSignal, useComputed, useSignalEffect } from "../runtime";
11
+ import { installAutoSignalTracking } from "../runtime/src/auto";
292
12
 
293
- const dispatcherTypeCache = new Map<ReactDispatcher, DispatcherType>();
294
- function getDispatcherType(dispatcher: ReactDispatcher | null): DispatcherType {
295
- // Treat null the same as the ContextOnlyDispatcher.
296
- if (!dispatcher) return ContextOnlyDispatcherType;
297
-
298
- const cached = dispatcherTypeCache.get(dispatcher);
299
- if (cached !== undefined) return cached;
300
-
301
- // The ContextOnlyDispatcher sets all the hook implementations to a function
302
- // that takes no arguments and throws and error. This dispatcher is the only
303
- // dispatcher where useReducer and useEffect will have the same
304
- // implementation.
305
- let type: DispatcherType;
306
- const useCallbackImpl = dispatcher.useCallback.toString();
307
- if (dispatcher.useReducer === dispatcher.useEffect) {
308
- type = ContextOnlyDispatcherType;
309
-
310
- // @ts-expect-error When server rendering, useEffect and useImperativeHandle
311
- // are both set to noop functions and so have the same implementation.
312
- } else if (dispatcher.useEffect === dispatcher.useImperativeHandle) {
313
- type = ServerDispatcherType;
314
- } else if (/Invalid/.test(useCallbackImpl)) {
315
- // We first check for warning dispatchers because they would also pass some
316
- // of the checks below.
317
- type = WarningDispatcherType;
318
- } else if (
319
- // The development mount dispatcher invokes a function called
320
- // `mountCallback` whereas the development update/re-render dispatcher
321
- // invokes a function called `updateCallback`. Use that difference to
322
- // determine if we are in a mount or update-like dispatcher in development.
323
- // The production mount dispatcher defines an array of the form [callback,
324
- // deps] whereas update/re-render dispatchers read the array using array
325
- // indices (e.g. `[0]` and `[1]`). Use those differences to determine if we
326
- // are in a mount or update-like dispatcher in production.
327
- /updateCallback/.test(useCallbackImpl) ||
328
- (/\[0\]/.test(useCallbackImpl) && /\[1\]/.test(useCallbackImpl))
329
- ) {
330
- // The update and rerender dispatchers have different implementations for
331
- // useReducer. We'll check it's implementation to determine if this is the
332
- // rerender or update dispatcher.
333
- let useReducerImpl = dispatcher.useReducer.toString();
334
- if (
335
- // The development rerender dispatcher invokes a function called
336
- // `rerenderReducer` whereas the update dispatcher invokes a function
337
- // called `updateReducer`. The production rerender dispatcher returns an
338
- // array of the form `[state, dispatch]` whereas the update dispatcher
339
- // returns an array of `[fiber.memoizedState, dispatch]` so we check the
340
- // return statement in the implementation of useReducer to differentiate
341
- // between the two.
342
- /rerenderReducer/.test(useReducerImpl) ||
343
- /return\s*\[\w+,/.test(useReducerImpl)
344
- ) {
345
- type = RerenderDispatcherType;
346
- } else {
347
- type = UpdateDispatcherType;
348
- }
349
- } else {
350
- type = MountDispatcherType;
351
- }
352
-
353
- dispatcherTypeCache.set(dispatcher, type);
354
- return type;
355
- }
356
-
357
- function isEnteringComponentRender(
358
- currentDispatcherType: DispatcherType,
359
- nextDispatcherType: DispatcherType
360
- ): boolean {
361
- if (
362
- currentDispatcherType & ContextOnlyDispatcherType &&
363
- nextDispatcherType & BrowserClientDispatcherType
364
- ) {
365
- // ## Mount or update (ContextOnlyDispatcher -> ValidDispatcher (Mount or Update))
366
- //
367
- // If the current dispatcher is the ContextOnlyDispatcher and the next
368
- // dispatcher is a valid dispatcher, we are entering a component render.
369
- return true;
370
- } else if (
371
- currentDispatcherType & WarningDispatcherType ||
372
- nextDispatcherType & WarningDispatcherType
373
- ) {
374
- // ## Warning dispatcher
375
- //
376
- // If the current dispatcher or next dispatcher is an warning dispatcher,
377
- // we are not entering a component render. The current warning dispatchers
378
- // are used to warn when hooks are nested improperly and do not indicate
379
- // entering a new component render.
380
- return false;
381
- } else if (nextDispatcherType & RerenderDispatcherType) {
382
- // Any transition into the rerender dispatcher is the beginning of a
383
- // component render, so we should invoke our hooks. Details below.
384
- //
385
- // ## In-place rerendering (e.g. Mount -> Rerender)
386
- //
387
- // If we are transitioning from the mount, update, or rerender dispatcher to
388
- // the rerender dispatcher (e.g. HooksDispatcherOnMount to
389
- // HooksDispatcherOnRerender), then this component is rerendering due to
390
- // calling setState inside of its function body. We are re-entering a
391
- // component's render method and so we should re-invoke our hooks.
392
- return true;
393
- } else {
394
- // ## Resuming suspended mount edge case (Update -> Mount)
395
- //
396
- // If we are transitioning from the update dispatcher to the mount
397
- // dispatcher, then this component is using the `use` hook and is resuming
398
- // from a mount. We should not re-invoke our hooks in this situation since
399
- // we are not entering a new component render, but instead continuing a
400
- // previous render.
401
- //
402
- // ## Other transitions
403
- //
404
- // For example, Mount -> Mount, Update -> Update, Mount -> Update, any
405
- // transition in and out of invalid dispatchers.
406
- //
407
- // There is no known transition for the following transitions so we default
408
- // to not triggering a re-enter of the component.
409
- // - HooksDispatcherOnMount -> HooksDispatcherOnMount
410
- // - HooksDispatcherOnMount -> HooksDispatcherOnUpdate
411
- // - HooksDispatcherOnUpdate -> HooksDispatcherOnUpdate
412
- return false;
413
- }
414
- }
415
-
416
- /**
417
- * We are exiting a component render if the current dispatcher is a valid
418
- * dispatcher and the next dispatcher is the ContextOnlyDispatcher.
419
- */
420
- function isExitingComponentRender(
421
- currentDispatcherType: DispatcherType,
422
- nextDispatcherType: DispatcherType
423
- ): boolean {
424
- return Boolean(
425
- currentDispatcherType & BrowserClientDispatcherType &&
426
- nextDispatcherType & ContextOnlyDispatcherType
427
- );
428
- }
429
-
430
- function WrapJsx<T>(jsx: T): T {
431
- if (typeof jsx !== "function") return jsx;
432
-
433
- return function (type: any, props: any, ...rest: any[]) {
434
- if (typeof type === "string" && props) {
435
- for (let i in props) {
436
- let v = props[i];
437
- if (i !== "children" && v instanceof Signal) {
438
- props[i] = v.value;
439
- }
440
- }
441
- }
442
-
443
- return jsx.call(jsx, type, props, ...rest);
444
- } as any as T;
445
- }
446
-
447
- const JsxPro: JsxRuntimeModule = jsxRuntime;
448
- const JsxDev: JsxRuntimeModule = jsxRuntimeDev;
449
-
450
- /**
451
- * createElement _may_ be called by jsx runtime as a fallback in certain cases,
452
- * so we need to wrap it regardless.
453
- *
454
- * The jsx exports depend on the `NODE_ENV` var to ensure the users' bundler doesn't
455
- * include both, so one of them will be set with `undefined` values.
456
- */
457
- React.createElement = WrapJsx(React.createElement);
458
- JsxDev.jsx && /* */ (JsxDev.jsx = WrapJsx(JsxDev.jsx));
459
- JsxPro.jsx && /* */ (JsxPro.jsx = WrapJsx(JsxPro.jsx));
460
- JsxDev.jsxs && /* */ (JsxDev.jsxs = WrapJsx(JsxDev.jsxs));
461
- JsxPro.jsxs && /* */ (JsxPro.jsxs = WrapJsx(JsxPro.jsxs));
462
- JsxDev.jsxDEV && /**/ (JsxDev.jsxDEV = WrapJsx(JsxDev.jsxDEV));
463
- JsxPro.jsxDEV && /**/ (JsxPro.jsxDEV = WrapJsx(JsxPro.jsxDEV));
13
+ export {
14
+ signal,
15
+ computed,
16
+ batch,
17
+ effect,
18
+ Signal,
19
+ type ReadonlySignal,
20
+ useSignal,
21
+ useComputed,
22
+ useSignalEffect,
23
+ };
464
24
 
465
25
  declare module "@preact/signals-core" {
466
26
  // @ts-ignore internal Signal is viewed as function
@@ -468,41 +28,4 @@ declare module "@preact/signals-core" {
468
28
  interface Signal extends ReactElement {}
469
29
  }
470
30
 
471
- /**
472
- * A wrapper component that renders a Signal's value directly as a Text node.
473
- */
474
- function Text({ data }: { data: Signal }) {
475
- return data.value;
476
- }
477
-
478
- // Decorate Signals so React renders them as <Text> components.
479
- Object.defineProperties(Signal.prototype, {
480
- $$typeof: { configurable: true, value: ReactElemType },
481
- type: { configurable: true, value: Text },
482
- props: {
483
- configurable: true,
484
- get() {
485
- return { data: this };
486
- },
487
- },
488
- ref: { configurable: true, value: null },
489
- });
490
-
491
- export function useSignal<T>(value: T) {
492
- return useMemo(() => signal<T>(value), Empty);
493
- }
494
-
495
- export function useComputed<T>(compute: () => T) {
496
- const $compute = useRef(compute);
497
- $compute.current = compute;
498
- return useMemo(() => computed<T>(() => $compute.current()), Empty);
499
- }
500
-
501
- export function useSignalEffect(cb: () => void | (() => void)) {
502
- const callback = useRef(cb);
503
- callback.current = cb;
504
-
505
- useEffect(() => {
506
- return effect(() => callback.current());
507
- }, Empty);
508
- }
31
+ installAutoSignalTracking();
@@ -7,6 +7,7 @@ import {
7
7
  useComputed,
8
8
  useSignalEffect,
9
9
  useSignal,
10
+ Signal,
10
11
  } from "@preact/signals-react";
11
12
  import {
12
13
  createElement,
@@ -139,6 +140,24 @@ describe("@preact/signals-react updating", () => {
139
140
  expect(scratch.textContent).to.equal("bar");
140
141
  });
141
142
 
143
+ it("should subscribe to signals passed as props to DOM elements", async () => {
144
+ const className = signal("foo");
145
+ function App() {
146
+ // @ts-expect-error React types don't allow signals on DOM elements :/
147
+ return <div className={className} />;
148
+ }
149
+
150
+ await render(<App />);
151
+
152
+ expect(scratch.innerHTML).to.equal('<div class="foo"></div>');
153
+
154
+ await act(() => {
155
+ className.value = "bar";
156
+ });
157
+
158
+ expect(scratch.innerHTML).to.equal('<div class="bar"></div>');
159
+ });
160
+
142
161
  it("should activate signal accessed in render", async () => {
143
162
  const sig = signal(null);
144
163
 
@@ -434,6 +453,69 @@ describe("@preact/signals-react updating", () => {
434
453
  });
435
454
  expect(scratch.innerHTML).to.equal("<div>1 1</div>");
436
455
  });
456
+
457
+ it("should not subscribe to computed signals only created and not used", async () => {
458
+ const sig = signal(0);
459
+ const childSpy = sinon.spy();
460
+ const parentSpy = sinon.spy();
461
+
462
+ function Child({ num }: { num: Signal<number> }) {
463
+ childSpy();
464
+ return <p>{num.value}</p>;
465
+ }
466
+
467
+ function Parent({ num }: { num: Signal<number> }) {
468
+ parentSpy();
469
+ const sig2 = useComputed(() => num.value + 1);
470
+ return <Child num={sig2} />;
471
+ }
472
+
473
+ await render(<Parent num={sig} />);
474
+ expect(scratch.innerHTML).to.equal("<p>1</p>");
475
+ expect(parentSpy).to.be.calledOnce;
476
+ expect(childSpy).to.be.calledOnce;
477
+
478
+ await act(() => {
479
+ sig.value += 1;
480
+ });
481
+ expect(scratch.innerHTML).to.equal("<p>2</p>");
482
+ expect(parentSpy).to.be.calledOnce;
483
+ expect(childSpy).to.be.calledTwice;
484
+ });
485
+
486
+ it("should properly subscribe and unsubscribe to conditionally rendered computed signals ", async () => {
487
+ const computedDep = signal(0);
488
+ const renderComputed = signal(true);
489
+ const renderSpy = sinon.spy();
490
+
491
+ function App() {
492
+ renderSpy();
493
+ const computed = useComputed(() => computedDep.value + 1);
494
+ return renderComputed.value ? <p>{computed.value}</p> : null;
495
+ }
496
+
497
+ await render(<App />);
498
+ expect(scratch.innerHTML).to.equal("<p>1</p>");
499
+ expect(renderSpy).to.be.calledOnce;
500
+
501
+ await act(() => {
502
+ computedDep.value += 1;
503
+ });
504
+ expect(scratch.innerHTML).to.equal("<p>2</p>");
505
+ expect(renderSpy).to.be.calledTwice;
506
+
507
+ await act(() => {
508
+ renderComputed.value = false;
509
+ });
510
+ expect(scratch.innerHTML).to.equal("");
511
+ expect(renderSpy).to.be.calledThrice;
512
+
513
+ await act(() => {
514
+ computedDep.value += 1;
515
+ });
516
+ expect(scratch.innerHTML).to.equal("");
517
+ expect(renderSpy).to.be.calledThrice; // Should not be called again
518
+ });
437
519
  });
438
520
 
439
521
  describe("useSignal()", () => {
@@ -1,8 +1,6 @@
1
1
  import { signal, useSignalEffect } from "@preact/signals-react";
2
- import { expect } from "chai";
3
2
  import { createElement } from "react";
4
3
  import { renderToStaticMarkup } from "react-dom/server";
5
- import sinon from "sinon";
6
4
  import { mountSignalsTests } from "../shared/mounting";
7
5
 
8
6
  describe("renderToStaticMarkup", () => {
@@ -112,7 +112,8 @@ export function checkConsoleErrorLogs(): void {
112
112
  if (errorSpy.called) {
113
113
  let message: string;
114
114
  if (errorSpy.firstCall.args[0].toString().includes("%s")) {
115
- message = consoleFormat(...errorSpy.firstCall.args);
115
+ const firstArg = errorSpy.firstCall.args[0];
116
+ message = consoleFormat(firstArg, ...errorSpy.firstCall.args.slice(1));
116
117
  } else {
117
118
  message = errorSpy.firstCall.args.join(" ");
118
119
  }
package/src/internal.d.ts DELETED
@@ -1,12 +0,0 @@
1
- export interface Effect {
2
- _sources: object | undefined;
3
- _start(): () => void;
4
- _callback(): void;
5
- _dispose(): void;
6
- }
7
-
8
- export interface JsxRuntimeModule {
9
- jsx?(type: any, ...rest: any[]): unknown;
10
- jsxs?(type: any, ...rest: any[]): unknown;
11
- jsxDEV?(type: any, ...rest: any[]): unknown;
12
- }