Skip to content

Inventaire comportemental exhaustif — @preact/signals-core@1.14.4 ​

Baseline figée pour signalcn. Toute affirmation ci-dessous provient d'une probe exécutée, pas d'une lecture de code. La source de référence est package/src/index.ts (1138 lignes) du tarball preact-signals-core-1.14.4.tgz ; les numéros de ligne L… renvoient à ce fichier.

Où une observation a été faite ​

Le référent est le paquet installé, pas ses sources. Une propriété s'y appelle i, pas _version : le paquet minifie les siennes. Une entrée dont l'observable ne peut rien porter sur l'artefact est marquée comme telle dans COUVERTURE — source:<réf> ou divergence:<ancre> — et sa raison est au §21 de la SPEC. Elle reste ici, telle quelle : elle dit ce que la référence en source fait, ce qui reste vrai d'elle. Ce qui est faux, c'est de la présenter comme une observation de l'artefact — et le marqueur, désormais, le dit.

Les lignes BRANCHE ne sont pas des doublons. Ce sont les compléments nés d'un découpage : une entrée qui asserte un comportement confrontable et un nom qui ne l'est pas a été coupée en deux. La branche garde l'observable de la mère — c'est le même fait observé par un autre point — et ce qui la sépare est le point d'observation, jamais le comportement. Douze branches.

La campagne qui a établi tout cela pour les 32 entrées hors harnais, verdict par verdict, est matrice-hors-harnais.md.

Méthode de reproduction ​

  1. npm pack @preact/signals-core@1.14.4 puis décompression → package/src/index.ts.
  2. Projet jetable /tmp/opencode/probe : la lib est chargée depuis src/index.ts via tsx (import … from "./lib.ts"), donc les noms d'internes observés correspondent exactement aux L….
  3. Chaque comportement est un probe("nom", fn) isolé, exécuté dans un try/catch et journalisé séparément : un probe qui throw n'annule pas les suivants.
  4. Les comportements dépendants de la strictness du module consommateur ont été rejoués sur les bundles réellement publiés : dist/signals-core.js (CJS, sloppy) et dist/signals-core.mjs (ESM, strict). Les bundles sont manglés donc leurs noms internes ne sont pas utilisés comme référence, seulement comme contrôle de parité.
  5. Contrôle de parité : tous les chiffres cités (compteurs de cycle, ordre de flush, -0, NaN) ont été reproduits à l'identique sur src/index.ts et sur les bundles CJS et ESM.

Node utilisé : v24.18.1.

Sommaire de l'inventaire ​

231 comportements documentés, répartis ainsi :

SectionComportementsEntrées marquées « non documenté »
Zones non documentées6060
signal(initialValue, options?)2618
computed(fn, options?)3022
effect(fn, options?)4329
batch(fn)2719
untracked(fn)137
action(fn)117
createModel(factory)3830
Signal.prototype.subscribe(fn)1817
Conversions toString / toJSON / valueOf / brand1512
dispose et Symbol.dispose105
Total231166

Les deux colonnes comptent la même population : les lignes de la matrice qui ont une entrée de leur côté dans le registre, branche comprise. Deux lignes ne s'additionnent donc pas. La ligne des zones n'est pas une onzième section : ce sont les 60 zones transverse, déjà comptées dans les tables par primitive. Et six lignes réobservent un fait déjà entré sous un autre point d'observation, sans entrée propre — action#12, action#13, dispose#11 à dispose#14 — d'où 11 entrées pour 13 lignes côté action, et 10 pour 14 côté dispose.


Zones non documentées ​

C'est la partie la plus importante du livrable : pour tout ce qui suit, ni le README.md ni le CHANGELOG.md du paquet ne peuvent servir d'oracle. Ces comportements ont été établis uniquement par probe.

A. Ordre d'exécution et gestion d'erreurs ​

  1. L'ordre de flush est l'inverse de l'ordre de notification, la file batchedEffect étant une pile en prepend. Pour un seul signal, les targets sont en insertion en tête, donc les effets s'exécutent dans l'ordre d'abonnement (donc de création). Pour un batch multi-écritures, l'ordre est l'inverse de l'ordre des écritures. Observé : effets A,B,C sur a,b,c, batch(() => { a=1; b=1; c=1 }) → ["C:1","B:1","A:1"]. Effets d1..d4 sur le même signal, a=1 → ["d4:1","d3:1","d2:1","d1:1"] (ordre de création).
  2. La première erreur mémorisée est la première rencontrée dans l'ordre de flush, pas la première chronologique. Observé : ["A","B","B","A","caught:EB"] — l'effet B tourne avant A, donc EB est re-throwée et EA est perdue.
  3. L'erreur d'effet écrase l'erreur du corps du batch. Observé : corps qui throw BODY et effet qui throw EFFECT → l'appelant reçoit EFFECT. Le finally { endBatch() } du batch (L101-105) throw pendant le unwinding et remplace l'exception en cours. Idem avec un batch imbriqué : ["EFFECT"].
  4. Le seuil de cycle est 100 itérations de flush, et l'erreur part du setter après 102 exécutions d'effet. Observé : effect(() => { n++; a.value = a.value + 1 }) → l'effet s'exécute 102 fois, a vaut 101, puis Error: Cycle detected. Le premier run est direct (hors flush, batchIteration === 0) ; chaque re-run incrémente batchIteration avant d'exécuter, donc le 102ᵉ run voit batchIteration === 101 > 100.
  5. Deux mécanismes de cycle distincts. Le setter (L463) dépend de batchIteration ; le getter d'un Computed (L753) dépend du flag RUNNING et ne dépend pas du compteur. Lire un computed en cours de calcul throw Cycle detected immédiatement, même hors batch.
  6. Cycle detected est un Error (pas un TypeError), message exact "Cycle detected", name === "Error".
  7. Un cleanup qui throw dispose l'effet, y compris en pleine flush. Observé : après cleanup boom, l'effet ne se ré-exécute plus et n'est plus abonné.
  8. Un cleanup qui throw interrompt la boucle de dispose d'un modèle : les modèles suivants ne sont pas disposés. Observé : ["cleanup1","cleanup2 throws"], err: "m boom", cleanup3 jamais exécuté.
  9. L'erreur d'un premier run d'effet dispose l'effet et ne renvoie aucun disposer : effect() retourne undefined (la variable n'est même pas assignée). signal._targets redevient undefined : watched puis unwatched sont tous deux déclenchés.
  10. L'erreur d'un computed est mémorisée et re-throwée par peek, toString, valueOf et toJSON aussi, pas seulement par .value. Observé : les six lectures throw "boom", le corps n'est évalué qu'une fois (calls: 1), (c)._value instanceof Error === true.
  11. L'Error mémorisée est réutilisée tant qu'aucune écriture n'a eu lieu : deux lectures successives ne réévaluent pas le corps (calls: 1) et re-throwent la même instance. Après une écriture de la dépendance, le corps est réévalué (calls: 2) : soit l'erreur est remplacée par une nouvelle ("always 0" → "always 1"), soit le computed redevient valide. Chaque échec incrémente _version (1 puis 2).
  12. Un effet disposé alors qu'il est dans la file de flush est sauté silencieusement (L62) : Observé : d1 disposé par d2 pendant la flush → le journal contient "d1:1" mais pas "d2:1".
  13. L'ordre de drain est BFS sur les itérations de flush : la chaîne en cours est vidée entièrement avant la chaîne suivante, les effets notifiés pendant l'exécution repartent dans une nouvelle chaîne.
  14. Un effet qui écrit une dépendance qu'il lit s'auto-notifie et se ré-exécute dans la même flush (autant de fois que nécessaire). Observé : ["run:0","run:1","run:2","run:3"] pour if (v < 3) a.value = v + 1.

B. Structure d'objets observable ​

  1. Computed.prototype est une instance unique et partagée de Signal (L644). Observé : Object.getPrototypeOf(c1) === Object.getPrototypeOf(c2) === true. Ses 12 clés d'instance sont donc visibles par for..in sur chaque computed.
  2. Lire .value sur Computed.prototype throw TypeError: this._fn is not a function et empoisonne définitivement le prototype partagé : _flags passe à 16 (HAS_ERROR), _value devient une TypeError, _version passe à 1, et 3 nouvelles clés apparaissent (_flags, _globalVersion, _sources). Observé avant/après : 12 clés → 15 clés. Les deux lectures suivantes throw le même message. Les computeds réels continuent de fonctionner (c2.value === 2).
  3. Object.keys(computed) expose 12 propriétés d'instance ; for..in en expose 22 (les 10 propriétés énumérables du prototype s'y ajoutent ; value est non énumérable).
  4. Object.keys(signal) et Object.keys(new Effect(...)) incluent toujours name, même absent (valeur undefined), car le constructeur fait une affectation inconditionnelle.
  5. EffectOptions.name n'est pas observable via la valeur de retour de effect() : la fonction retournée est effect._dispose.bind(effect), son .name vaut "bound ". Le nom n'est visible que via new Effect(fn, { name }) (classe exportée) ou via les internes : l'effet créé par subscribe porte name === "sub" (s._targets._target.name).
  6. SignalOptions.name est un simple champ public, mutable après coup.
  7. this dans le callback d'un effet est l'instance Effect et non undefined : les 6 clés privées (_fn, _cleanup, _sources, _nextBatchedEffect, _flags, name) et _start/_callback/dispose sont publiquement atteignables. Mais seulement pour une fonction non fléchée : une flèche capture le this lexical du module.
  8. this dans un callback subscribe n'est pas l'effet : le callback est appelé en fn(value) depuis une flèche. Observé : this === globalThis en CJS sloppy, undefined en ESM strict. Idem pour this dans une fonction passée à action() et dans la factory de createModel : c'est la strictness du module consommateur qui décide.
  9. Affectation à computed.value : Computed.prototype.value n'a pas de setter. En sloppy : no-op silencieux. En strict (bundle ESM) : TypeError: Cannot set property value of [object Object] which has only a getter.
  10. createModel qui renvoie une primitive ne throw pas en sloppy (affectation de propriété sur une primitive = no-op silencieux) et throw en strict : TypeError: Cannot create property 'Symbol(Symbol.dispose)' on number '42'.
  11. signal gelé (Object.freeze) : écriture → TypeError: Cannot assign to read only property.

C. createModel / action (la surface la moins documentée) ​

  1. createModel mute l'objet retourné par la factory en place (L1076-1087, L1109) et le retourne tel quel. Observé : inst === shared, et shared.inc.name passe de "inc" à "actionWrapper" après new M().
  2. instanceof est toujours faux, instance.constructor === Object. L'objet n'est pas une instance de la "classe" retournée.
  3. Appelable sans new au runtime, y compris depuis le TypeScript compilé.
  4. wrapInAction n'utilise que for…in → les méthodes de prototype de classe ne sont pas wrappées. Observé : class Foo { m(){} n = signal(1) } → inst.m.name === "m".
  5. wrapInAction ne descend pas dans les objets qui ont brand (donc pas dans un signal) et ne touche pas null/primitives. Un Date reste la même référence.
  6. wrapInAction boucle infiniment sur un objet cyclique : RangeError: Maximum call stack size exceeded, l'objet étant partiellement muté avant le crash.
  7. Les getters sont invoqués une seule fois, par le for…in de wrapInAction, et le résultat est réassigné comme propriété de données. Un getter qui renvoie un signal le fait entrer dans le modèle.
  8. Les effets créés dans un getter ne sont pas possédés : wrapInAction s'exécute aprèsstopCapturingEffects(). Observé : l'effet tourne, puis survit au model[Symbol.dispose]().
  9. Les effets d'un modèle imbriqué sont remontés dans la propriété du parent (L1064-1073). Observé : dispose du modèle externe → les cleanups des deux modèles imbriqués tournent, et les signaux du modèle interne deviennent inertes.
  10. Les effets créés dans un untracked() ou une action() pendant une factory ne sont pas possédés (CHANGELOG 1.14.4 le mentionne pour untracked et action, mais le README n'en parle pas et le mécanisme — remise à undefined puis restauration — n'est pas décrit).
  11. Les effets créés dans un batch() pendant une factory sont possédés : batch ne touche pas capturedEffects.
  12. Un effet créé dans un cleanup pendant model[Symbol.dispose]() n'est pas possédé et fuit.
  13. Si la factory throw, les effets capturés sont perdus (L1099-1104) : capturedEffects est remis à undefined, donc aucune instance ultérieure ne les récupère. Observé : l'effet leaké s'exécute une fois et survit à tout dispose de modèle.
  14. Les effets créés par signal.subscribe() dans une factory SONT possédés (ce sont des Effect comme les autres).
  15. model[Symbol.dispose] écrase un Symbol.dispose fourni par l'utilisateur, silencieusement.
  16. model[Symbol.dispose] est un action : il batche les disposes. Les cleanups sont flushing dans l'ordre, un par un.
  17. Object.keys(model) ne contient jamais Symbol.dispose ; Object.getOwnPropertySymbols(model) contient exactement Symbol(Symbol.dispose).
  18. action() retourne une fonction nommée "actionWrapper", de length 0 quel que soit le nombre d'arguments déclarés, brand absent.

D. Divers ​

  1. Signal et Computed appelé sans new : Signal(5) → TypeError: Cannot read properties of undefined (strict) ; en sloppy le this devient le global et la valeur est écrite sur le global.
  2. Aucune validation à l'exécution dans createModel : { anything: 42, fn: () => 1 } passe, la fonction est wrappée. ValidateModel n'est qu'un type.
  3. Les dépendances dynamiques sont réactivées : après abandon puis re-lecture, l'ancien nœud est réutilisé et les bonnes dépendances sont réabonnées. Observé : ["1","10","2","3"] — après t=true, a=2 ne notifie plus ; après t=false, a=3 notifie à nouveau.
  4. Le compteur d'évaluations d'un computed invalidé puis relu dépend de la version globale.Observé : c.value trois fois → 1 évaluation ; puis a=1; b=2 sans abonné ; puis c.value → 2ᵉ évaluation et la valeur intègre les deux écritures (12).
  5. Le correctif 1.14.4 (recordBatchSnapshot / reconcileBatchSnapshots) est observable. Observé : batch(() => { a=1; c.value; a=0 }) → le computed est évalué 3 fois au total, l'effet tourne 2 fois (la seconde avec la valeur revertie 0). Sans lecture intermédiaire, l'effet ne tourne pas du tout (cCalls: 1, eRuns: 1).
  6. Le snapshot n'est enregistré que pendant le corps du batch utilisateur (L165 : batchDepth === 0 || batchIteration !== 0) : une écriture faite par un effect pendant le flush n'entre pas dans le snapshot, donc pas de fast-forward. Observé : les trois scénarios de revert n'ont pas le même comportement (#16, #17, #18 ci-dessous) — le fast-forward ne s'applique qu'aux nodes qui ont vu la version pré-batch.
  7. untracked restaure evalContext ET capturedEffects même en cas d'exception (finally).
  8. watched/unwatched sont appelés dans un untracked : les lectures de signal faites dans ces callbacks n'abonnent personne. Observé : "watched reads other=X". Une écriture dans watched est visible par l'effet qui est en train de s'abonner (t.value = 1 puis l'effet lit t === 1).
  9. watched d'un computed se déclenche avant ceux de ses sources, et unwatched dans l'ordre inverse. Observé : ["c:watched","a:watched","b:watched"] puis ["c:unwatched","a:unwatched","b:unwatched"].
  10. Si watched ou unwatched throw, l'erreur remonte à travers effect()/dispose() et l'effet est disposé.
  11. Une exception dans un cleanup laisse evalContext intact (le throw a lieu avant evalContext = undefined dans _start, L928 vs L933) : pas de "Out-of-order effect".
  12. "Out-of-order effect" est inatteignable par l'API publique ; atteint seulement en appelant deux fois le finish renvoyé par Effect.prototype._start sur la classe exportée. Observé : Error: Out-of-order effect.
  13. Un cleanup qui lit le signal dont il dépend ne le réabonne pas, mais lit la valeur courante (post-écriture). Observé : "cleanup sees 7".
  14. Les écritures faite dans un cleanup sont différées si le dispose a lieu pendant une flush, mais immédiates s'il a lieu hors flush. Observé : pendant la flush, l'autre effet tourne après le premier ; hors flush, il tourne pendant le dispose().
  15. Symbol.dispose absent ⇒ clé de propriété "undefined" (voir §dispose et Symbol.dispose, ligne 5).
  16. toString() sur un Symbol throw TypeError: Cannot convert a Symbol value to a string (le message vient de V8, pas de la lib). Aucun Symbol.toPrimitive n'est défini : signal(1)[Symbol.toPrimitive] === undefined. La conversion string passe donc par toString/valueOf selon le type de la valeur.
  17. JSON.stringify(signal(5)) → "5" grâce à toJSON.

signal(initialValue, options?) ​

#ComportementLocalisation src/index.tsObservé (résultat du probe)Documenté ?Quirk figé
1Crée une instance SignalL494-498signal(1).value === 1, _version === 0oui—
2Sans argument → undefinedL494-497signal().value === undefined, identique à signal(undefined)partiellement (CHANGELOG 1.7.0)—
38 propriétés d'instance, dans cet ordreL368-377Object.keys(signal(1)) = ["_value","_version","_node","_targets","_batchSnapshotVersion","_watched","_unwatched","name"]nonordre exact des clés
4Pas de _flags sur un SignalL368-377"_flags" in signal(1) === false, signal(1)._flags === undefinednonun Signal n'est pas un Effect
5Écriture → notification immédiate et synchroneL461-485["e:0","before","e:1","after"]oui (README §signal)le flush est terminé avant la fin de l'instruction
6Égalité !== stricte : NaN notifieL462s = NaN, effect re-exécuté 2 fois, _version === 1nonObject.is n'est pas utilisé
6bBRANCHE — Égalité !== stricte : NaN notifieL462s = NaN, effect re-exécuté 2 fois, _version === 1nonObject.is n'est pas utilisé
70 → -0 ne notifie pasL4621 run au total, _version === 0, Object.is(value,-0) === false (valeur restée 0)non—
7bBRANCHE — 0 → -0 ne notifie pasL4621 run au total, _version === 0, Object.is(value,-0) === false (valeur restée 0)non—
8-0 → 0 ne notifie pasL4621 run, _version === 0, Object.is(value,-0) === true (valeur restée -0)non—
8bBRANCHE — -0 → 0 ne notifie pasL4621 run, _version === 0, Object.is(value,-0) === true (valeur restée -0)non—
9Nouvel objet de même forme notifieL4622 runs, _version === 1 ; ré-écriture de l'objet d'origine → _version === 2nonidentité, pas égalité structurelle
100 → undefined notifie ; undefined → undefined nonL4622 runs / 1 runnon—
11Écriture identique : aucune notificationL4621 run, _version inchangénon—
12Auto-écriture dans un effect : aucune boucleL462effect(() => { n++; s.value = s.value }) → n === 1non—
13Écriture hors de tout effect : aucun flushL481-483effect sans dépendance : n === 1 après 2 écrituresoui (README §effect « lazy »)—
14peek() = untracked(() => value)L449-451effect lisant s.peek() : 1 run malgré s=1oui (README + CHANGELOG 1.0.0/1.0.1)—
15peek() est énumérable et inscriptible sur le prototypeL449descripteur {writable:true, enumerable:true, configurable:true}non—
16options.name → champ public mutableL376signal(1,{name:"n"}).name === "n", signal(1).name === undefined, "" conservéoui (README §options)toujours présent dans Object.keys
17brand sur le prototypeL379typeof s.brand === "symbol", String(s.brand) === "Symbol(preact-signals)", énumérable/inscriptiblepartiellement (CHANGELOG 1.5.0)inscriptible : s.brand = "x" crée une prop d'instance
18instanceof Signal vraiL300,L368s instanceof Signal === true, proto === Signal.prototypenon—
19new Signal(5) : signal mutableL368.value = 2 → 2nonla classe est exportée
20Signal(5) sans newL368TypeError: Cannot read properties of undefined (strict)nonle corps n'est pas validé
21Signal gelé (Object.freeze)L461-485TypeError: Cannot assign to read only property en strict (le nom de propriété dans le message dépend du build : _value en source, v dans le bundle)nondépend de la strictness
22brand commun à toutes les versionsL3signal(1).brand === Signal.prototype.brandoui (CHANGELOG 1.5.0)Symbol.for, donc inter-réalms
23Aucune méthode dispose sur un Signal/ComputedL300,L622typeof signal(1).dispose === "undefined", idem pour un computednonseul l'effet retourné est disposible

computed(fn, options?) ​

#ComportementLocalisation src/index.tsObservé (résultat du probe)Documenté ?Quirk figé
1Paresseux : rien ne s'exécute avant la première lectureL640-641,L661-697callsBefore === 0, callsAfter === 1oui (README « lazy »)—
2Valeur mise en cache si résultat identiqueL679-6873 lectures consécutives → 1 évaluationoui (CHANGELOG 1.1.1)—
3Sans abonné, une écriture ne relance rienL737-749après a=1, calls === 1, c.value === 2, calls === 2oui (README)—
3bBRANCHE — Sans abonné, une écriture non liée ne fait pas non plus réévaluerL669c(a) puis z=1; z=2, puis c.value → calls === 1 ; puis a=10 puis c.value → calls === 2, valeur 11nonla voie rapide absorbe le compteur global quand tous les nœuds sont à jour
4Invalidation par écriture, mais invalidé-puis-relu ≠ 1 évaluationL646-697c.value ×2 → 1 ; a=1;b=2 ; c.value → calls === 2, valeur 12nonla version globale force un recalcul
5Un résultat identique ne notifie pas les dépendantsL679-687computed %2, effect : a=2 → cCalls: 2, eRuns: 1nonl'effet est notifié puis sauté par needsToRecompute
6Le cas symétrique notifieL679-687a=1 → cCalls: 2, eRuns: 2non—
7L'écriture du computé le déconnecte et le reconnecte paresseusementL699-735a=1; c.value; a=2; c.peek() → 3 évaluations, peek === 6oui (CHANGELOG 1.1.0 « stale value … deactivated »)—
8Dépendances dynamiques ajoutées/retiréesL533-617toggle true→false puis a="a2" : journal ["a","b","b2"], calls === 2oui (CHANGELOG 1.1.0 « conditional signals »)—
9Réactivation après abandon completL250-284journal ["1","10","2","3"], calls === 4nonà 100% non documenté
10L'ordre des dépendances est l'ordre de première lectureL508-531a*10+b : la 1ʳᵉ dépendance change suffitnonoptimisation interne
11Lecture re-entrée dans un batch : seul le chemin lu est recalculéL646-697journal ["0/0","mid:2","2/3"]oui (README §batch)—
12Auto-cycle : Error: Cycle detected immédiatL753-755computed(() => c.value + 1) → Error: Cycle detected, name === "Error"nonindépendant de batchIteration
13Cycle indirect à la relectureL753,L646-6512 computeds mutuels : 1ʳᵉ lecture 1, après s=1 → Cycle detectednon—
14Écriture de signal dans un computé autoriséeL678journal ["eval:0","e:0 mirror=0","eval:1","e:1 mirror=2"]oui (CHANGELOG 1.6.0)l'effet voit la valeur déjà écrite
15Erreur stockée dans _value + flag HAS_ERRORL688-6926 lectures successives throw "boom", calls === 1, _value instanceof Error, _flags & 16nonpeek/toString/valueOf/toJSON propagent aussi
16L'erreur ne se résout que si une dépendance changeL646-697après s=1 : c.value === 20, calls === 2nonpas de nouvelle tentative sur simple relecture
17Propagation de l'erreur vers l'effect qui la litL761-763["create threw derived boom2","runs=1","c=1"] : l'effect est disposé à la création, s=1 ne le relance pasnonl'effect ne survit pas à l'erreur du computé
182 computeds → les 2 re-évaluent dans le même flushL737-749["c1:0","c2:1","e1:2","e2:2"] puis après a=1 ["c1:1","c2:2","e1:3","e2:3"]nonordre bottom-up : c1, c2, puis l'effet
18bBRANCHE — 2 computeds indépendants et 2 effets : l'ordre alterne, pas bottom-upL737-749["c1:0","e1:1","c2:0","e2:2"] puis après a=1 ["c1:1","e1:2","c2:1","e2:3"]nonun computé n'est pas une génération de drainage : chaque effet suit le computé qu'il lit
19Computed.prototype est une instance de Signal partagéeL644Object.getPrototypeOf(c1) === Object.getPrototypeOf(c2) === truenonsingleton mutable
20Object.keys(computed) = 12 clés d'instanceL635-642["_value","_version","_node","_targets","_batchSnapshotVersion","_watched","_unwatched","name","_fn","_sources","_globalVersion","_flags"]nonliste exacte
21for…in sur un computed = 22 clésL644+ ["_refresh","_subscribe","_unsubscribe","_notify","brand","subscribe","valueOf","toString","toJSON","peek"]non22 = 12 + 10
22Lire .value sur le prototype throw et l'empoisonneL644,L678TypeError: this._fn is not a function ; 12 → 15 clés, _flags === 16, _version === 1, computeds réels OKnoneffet de bord global au module
22bBRANCHE — Lire .value sur le prototype throw et l'empoisonneL644,L678TypeError: this._fn is not a function ; 12 → 15 clés, _flags === 16, _version === 1, computeds réels OKnoneffet de bord global au module
23instanceof Signal et instanceof Computed vrais, constructor.name === "Signal"L644true, true, "Signal"non—
24Pas de setter sur valueL751-766sloppy : no-op silencieux ; strict : TypeError: Cannot set property value …only a getternondépend de la strictness
25options.name champ publicL636computed(()=>1,{name:"c"}).name === "c", modifiable après coupoui (CHANGELOG 1.12.0)—
26subscribe sur un computed : notification immédiate puis à chaque changementL427-435[0,2,4], puis plus rien après unsub()nonvoir §Signal.subscribe
27brand présent → wrapInAction ne descend pas dedansL1081"brand" in computed(()=>1) === truenon—

effect(fn, options?) ​

#ComportementLocalisation src/index.tsObservé (résultat du probe)Documenté ?Quirk figé
1Premier run synchrone, avant le retourL969-972["e:0","after"]oui (README §effect)—
2Re-run à chaque changement de dépendanceL937-943["e:0","e:1","e:2"]oui—
3Sans dépendance : 1 run, jamais re-notifiéL937-943n === 1 après 2 écrituresoui—
4Retourne un dispositeur : fonction bound, name === "bound ", length === 0, Object.keys === []L979{"type":"function","name":"bound ","length":0}oui (README)ce n'est pas une arrow, ni l'instance Effect
5Valeur de retour non-fonction ignoréeL913-916effect(() => 42) : aucun cleanuppartiellement (CHANGELOG 1.3.1)—
6Cleanup appelé avant le run suivantL802-824,L928["run:0","--","cleanup:0","run:1","--","cleanup:1","--end"]oui (README, CHANGELOG 1.2.0)—
7Cleanup exécuté hors de tout contexte de suiviL810-811"cleanup reads other=5" : la lecture de other n'abonne pasoui (CHANGELOG 1.2.1 « cleanup functions always outside of any context »)—
8Le cleanup lit la valeur couranteL813"cleanup sees 7"non—
9this = instance Effect pour une fonction non fléchéeL913Object.keys(this) = ["_fn","_cleanup","_sources","_nextBatchedEffect","_flags","name"], this instanceof Effectpartiellement (README, CHANGELOG 1.10.0)API privée accessible via this
10this n'est pas l'effet pour une flècheL913typeof this === "object", hasDispose === "undefined"partiellement (README dit « non-arrow »)piège : une flèche capture le this du module
11this.dispose() pendant le run : cleanup immédiat, plus de re-runL840-852["run 0","cleanup 0","--"], s=1 sans effetoui (CHANGELOG 1.2.0)le cleanup part via endEffect→disposeEffect
12this.dispose() appelé 2× pendant le run : 1 seul cleanupL945-951["run:0","cleanup:0"]non—
13dispose() externe : idempotentL945-9553 appels → 1 cleanup, plus de runnon—
14Effet disposé dans la file de flush : sauté, aucun callbackL62["d1:0","d2:0","d1:1"] — d2 absentnonvérifie le flag DISPOSED avant needsToRecompute
15Effet A qui dispose B après le run de B : B tourne une foisL51-74["d1:0","d2:0","d1:1","d1 disposed d2"]nonordre BFS
16Effet qui s'écrit lui-même : re-run dans la même flushL51-74,L937-943["run:0","run:1","run:2","run:3"]non—
17Effet qui écrit le signal d'un autre effet : l'autre tourne aprèsL51-74["d2:0","d1:0","d1:1","d2:1","d1:1"] (d1 tourne 2×)nonordre : chaîne courante vidée, puis nouvelle
18Auto-cycle : Cycle detected après 102 runsL463,L907-920{"n":102,"err":"Cycle detected","a":101}nonseuil 100 itérations
19Cycle borné : pas d'erreurL463if (v < 50) a = v+1 → 51 runs, pas d'erreurnon—
20Erreur au 1ᵉʳ run : effet disposé, propagée, aucun dispositeur retournéL971-976{"runs":1,"dUndefined":true,"targets":true,"runsAfter":1}oui (CHANGELOG 1.2.3)effect() ne retourne rien
21watched/unwatched déclenchés autour de cet effetL394-396,L419-421"W2 watched", "W2 unwatched", _targets === undefinednoncouplés à l'échec du premier run
22Erreur à un re-run : l'effet survitL907-920["run:0","run:1","threw:boom","run:2"]nonasymétrie avec le 1ᵉʳ run
231ʳᵉ erreur mémorisée = 1ʳᵉ dans l'ordre de flushL65-70["A","B","B","A","caught:EB"]nonordre LIFO
24Drain complet : les effets sans erreur tournentL51-74["A","B","C","C","B","A","caught:EA"]non—
25Erreur re-throwée depuis le setter si écriture hors batchL481-483write threw: E, value now: 1non—
26Erreur re-throwée depuis le batch, pas depuis le corpsL101-105["e:0","e:1","caught:body"]non—
27L'erreur d'effet écrase l'erreur du corpsL101-105["EFFECT"] (le BODY est perdu)nonfinally throw pendant l'unwinding
28Cleanup qui throw → effet disposé, même en flushL814-818["run:0","cleanup0 throws","caught:cleanup boom"], s=2 sans runoui (CHANGELOG 1.2.1)flags forcés à DISPOSED
29Cleanup qui throw au dispose() : remonte, effet mortL814-818["run:0","cleanup:0","caught:cleanup boom"], s=1 sans runoui (CHANGELOG 1.2.1)—
30Un cleanup qui throw ne casse pas le contexteL928 vs L933pas de "Out-of-order effect"non—
31Écriture dans un cleanup : flush selon le contexteL807-822hors flush → "d2:1" pendant dispose() ; pendant la flush → "d2:99" après "d1:1"nondépend de batchDepth
323 niveaux (2 computeds + 2 effets) : ordreL737-749["c1:0","c2:1","d1:2","d2:0","c1:1","c2:2","d1:3","d2:1"]nonpendant la flush : c1, c2, e1, e2
33Effets créés dans un effect : indépendants, non possédésL902-905["outer:0","inner:0","inner:1"] : après outer.dispose(), l'inner reste abonné et tournenoncapturedEffects n'est actif que dans une factory
34Effet créé dans un computé : fuite à chaque évaluationL902-905["inner:0","outer:0","inner:0","outer:2","inner:1","inner:1"]nonun nouvel effet par évaluation
35options.name non observable via le retourL900,L979(d).name === "bound ", Object.keys(d) === []nonCHANGELOG 1.12.0 promet un nom pour le debug
36options.name observable via new Effect(...)L900e.name === "n", Object.keys(e) = ["_fn","_cleanup","_sources","_nextBatchedEffect","_flags","name"]non—
36bBRANCHE — options.name observable via new Effect(...)L900e.name === "n", Object.keys(e) = ["_fn","_cleanup","_sources","_nextBatchedEffect","_flags","name"]non—
37Flags initiauxL899,L641new Effect(fn)._flags === 32 (TRACKING) ; computed(fn)._flags === 4 (OUTDATED), _globalVersion === -1non—
38Le callback ne reçoit aucun argumentL913arguments.length === 0non—
39Effect exporté, méthodes énumérables sur le prototypeL894-955Object.keys(Effect.prototype) = ["_callback","_start","_notify","_dispose","dispose"]nonAPI interne entièrement publique
40"Out-of-order effect" atteignable seulement via _startL840-843double appel du finish → Error: Out-of-order effectnoninatteignable par l'API publique
40bBRANCHE — "Out-of-order effect" atteignable seulement via _startL840-843double appel du finish → Error: Out-of-order effectnoninatteignable par l'API publique
41effect(fn) retourné est utilisable avec usingL980["run","body","cleanup"]oui (CHANGELOG 1.11.0)—

batch(fn) ​

#ComportementLocalisation src/index.tsObservé (résultat du probe)Documenté ?Quirk figé
1Flush synchrone en fin de callbackL95-106["e:0","A","B","C","D","e:1","E"]oui (README §batch)—
2Coalesce les écrituresL51-743 écritures → ["e:0","e:3"]oui—
3Retourne la valeur du callbackL102batch(() => "ret") === "ret", référence d'objet préservéeoui (JSDoc)—
4Laisse remonter l'erreur du corps mais flush quand mêmeL101-105["e:0","e:1","caught:boom"]non—
5Imbriqué = simple fn(), sans startBatch/endBatchL96-98["A","B","C","D","e:1","E"] : rien n'est flushé avant la sortie du batch externeoui (README « nested »)⚠️ l'affirmation « sans comptabilité » est imprécise : batch() n'incrémente pas batchDepth, mais les setters internes font toujours startBatch/endBatch ; c'est batchDepth > 0 qui diffère le flush
6Imbriqué : la valeur de retour intérieure remonteL97batch(() => batch(() => "inner")) === "inner"non—
7Profondeur 3 : un seul flushL42-45["e:0","after3","after2","e:1"]non—
8Erreur du batch intérieur rattrapée dans l'extérieur : pas de flush intermédiaireL96-98["e:0","caught inner","after","e:1","end"]nonpas de try/finally dans le cas imbriqué
9Erreur extérieure avec batch ouvert : flush puis re-throwL101-105["e:0","e:1","caught:out"]non—
10batchDepth restauré après une erreurL103-105après un batch qui throw, les écritures suivantes flushent normalement (after: 2, final: 4)nonfinally indispensable
11Batch sans écriture : aucun flushL51n === 1non—
12Écritures identiques dans un batch : une seule notificationL462n === 2 (1er run + 1 flush)non—
13Un batch créé dans le flush d'un effect re-batcheL96-98["e1:0","e1:1","e1:2","e1:1","e1:2"]nonimbrication pendant le flush
14Ordre de flush = inverse de l'ordre de notificationL51-73,L937-943,L385-3993 effets distincts, batch(() => {a=1;b=1;c=1}) → ["C:1","B:1","A:1"] ; 4 effets sur le même signal → ["d4:1","d3:1","d2:1","d1:1"]nonrègle à écrire noir sur blanc
15Un effet notifié 2× ne tourne qu'une fois par flushL938-9423 écritures dans un batch → 1 runnonflag NOTIFIED
16A → B → A dans un batch : aucun re-runL180-206cCalls: 1, eRuns: 1oui (CHANGELOG 1.14.0)—
17A → B → A avec lecture paresseuse pendant le batch : l'effet re-tourneL180-206["e:0","mid:10","e:0","end:0"], cCalls: 3, eRuns: 2oui (CHANGELOG 1.14.4)le fast-forward ne s'applique qu'aux nodes qui ont vu la version pré-batch
18A → B → A puis écriture ultérieure : pas de valeur figéeL180-206["e:0","e:0","e:20"], cCalls: 4oui (CHANGELOG 1.14.4)régression corrigée en 1.14.4
19Revert sur un signal et changement sur un autre : le computé changeL180-206runs: 2oui (CHANGELOG 1.14.0)—
20Snapshot uniquement pendant le corps du batchL163-178les écritures de l'utilisateur sont snapshotées, celles des effets non (batchIteration !== 0) ; effet de bord : les 3 scénarios de revert (#16, #17, #18) diffèrentnonle fast-forward ne vise que les nodes ayant vu la version pré-batch
21Effet créé dans le corps du batch : son premier run est immédiatL969-972["before","new effect runs 0","after-create"]non—
22Cycle dans un batch : même seuil, 102 runsL463{"n":102,"err":"Cycle detected"}nonbatchIteration n'est incrémenté que dans le flush
23Cycle dans un batch appelé depuis un effect : 102 runsL463{"n":102,"err":"Cycle detected"}non—
24Ping-pong de 2 effets : 52 / 51 runs puis Cycle detectedL463{"an":52,"bn":51,"a":102,"b":101}nonchiffres exacts
25Ping-pong à 3 effets : 35 / 35 / 34 puis Cycle detectedL463{"an":35,"bn":35,"cn":34}nonchiffres exacts
26batchIteration remis à 0 après une erreurL75un effect auto-écrivant créé après un flush en erreur fait bien 102 runsnonsinon le seuil serait consommé
27Lire un computé invalidé pendant le batch ne consomme pas la file de drainageL737-749["e:A:a","mid:A:T","e:A:T"] : l'effet tourne à la sortie du batchnonun computé ne s'empile PAS dans la file ; il pose les drapeaux et prévient par un parcours

untracked(fn) ​

#ComportementLocalisation src/index.tsObservé (résultat du probe)Documenté ?Quirk figé
1Aucune dépendance suivie dans le callbackL126-141effect lisant a.value et untracked(() => b.value) : b=1 puis b=2 ne relancent pas, a=1 relanceoui (README, CHANGELOG 1.4.0)—
2Restaure le contexte en fin de callback, même imbriquéL127,L138["a=0","o=0","deep o=0","o2=0","a=1","o=1","deep o=1","o2=1"]oui (CHANGELOG 1.6.0)—
3Restaure le contexte après une exceptionL135-140["a=0","caught","a2=0","a=1","caught","a2=1"]nonfinally
4N'empêche pas les écrituresL136effect : ["a=0","after=99","a=99","after=99"] (auto-notification)non—
5Retourne la valeur du callbackL136untracked(() => 42) === 42, une Promise est renvoyée telle quelleoui (JSDoc)—
6Équivalent exact de peek() pour les lecturesL449-451effect : ["0/0","2/1"]oui (CHANGELOG 1.14.2)—
7Ne désactive pas le rafraîchissement d'un computed luL757untracked(() => c.value) : l'effet ne re-tourne pas, le computed garde sa valeur périméenonlire ≠ invalider
8Effet créé dans untracked : indépendant du parentL131-134,L902-905effect parent lisant a : 2 runs du parent → 2 effets internes créés, chacun lancé une fois (inner: 2)partiellement (CHANGELOG 1.14.4)les effets internes ne re-tournent pas avec le parent
9Effet créé dans untracked hors factory : rien à posséderL134effet « loose » exécuté, jamais disposé par un modèle ultérieurnon—
10capturedEffects neutralisé puis restauréL128,L132-134,L139un createModel imbriqué dans le untracked capture ses effets, le parent ne les possède pasoui (CHANGELOG 1.14.4, cité dans le JSDoc L118-121)le JSDoc est la seule source
11Capture restaurée après une exceptionL135-140["owned"] : l'effet suivant de la factory est bien possédénon—
12Effet créé dans un computé lu sous untracked : possédéL902["owned:0","owned:1"]nonla capture est replantée après stopCapturingEffects
13untracked est utilisé en interne pour peek, watched, unwatched et les callbacks subscribeL394,L419,L431,L450une lecture de signal dans watched n'abonne pasnon4 usages internes

action(fn) ​

#ComportementLocalisation src/index.tsObservé (résultat du probe)Documenté ?Quirk figé
1batch(untracked(fn))L991-9932 écritures → un seul run : ["e:0","e:12"]oui (CHANGELOG 1.13.0)—
2Retourne la valeur de fnL992action(() => "ret")() === "ret", une Promise traverseoui—
3Transmet les argumentsL991action((a,b) => a+b)(1,2) === 3ouilength === 0 malgré 2 params
4Transmet this (appel membre)L991{ n: 5, run: action(function(){ return this.n }) } → obj.run() === 5non—
5this = sloppy global / undefined si détachéL991-992CJS : this === globalThis ; ESM : this === undefinednondépend du module consommateur
6Le wrapper est nommé "actionWrapper"L991f.name === "actionWrapper", f.length === 0non—
7Lecture non suivie (untracked)L992effect lisant read.value puis appelant l'action qui lit read : 1 runoui (« untracked »)—
8Actions imbriquées : un seul flushL991-993["e:0","e:12"]nongrâce au L96-98
9L'erreur traverse, le flush a lieuL991-993["e:0","e:1","caught:x"]non—
10Écriture déclenchée depuis un effect : flush différé à la fin de l'effectL991-993 vs L851["d2:0","d1:0","d1:1","d2:9"]nonle batch de l'action s'imbrique dans celui de l'effect
11Pas de brand, pas de SignalL988-994"brand" in f === false, Object.getPrototypeOf(f) === Function.prototypenon—
12Effet créé dans une action pendant une factory : non possédéL131-134après model[Symbol.dispose](), l'effet continue : ["in-action e:0","owned e:0","from method e:0",|dispose|,"in-action e:1","from method e:1"]oui (CHANGELOG 1.14.4), non dans le READMEle CHANGELOG dit « untracked and action » ; le README n'en dit rien
13Effet créé dans une action appelée plus tard (méthode) : non possédéL902-905idem, "from method e:1" survit au disposenontrivial mais à figer

createModel(factory) ​

#ComportementLocalisation src/index.tsObservé (résultat du probe)Documenté ?Quirk figé
1Retourne une fonction-constructeur, appelable avec ou sans newL1092new M(1).s.value === 1 et M(1).s.value === 1oui (README utilise new)appel sans new toléré au runtime
2L'instance est l'objet brut de la factory, muté en placeL1109inst === shared, shared.inc.name passe de "inc" à "actionWrapper" après new M()nonmutation du closure de la factory
2bBRANCHE — L'instance est l'objet brut de la factory, muté en placeL1109inst === shared, shared.inc.name passe de "inc" à "actionWrapper" après new M()nonmutation du closure de la factory
3instanceof M faux, constructor === ObjectL1092-1122isM: false, ctorName: "Object", instanceof Object: truenonle CHANGELOG 1.13.0 parle de « classe »
4Toutes les fonctions own énumérables sont wrappéesL1076-1081inst.inc.name === "actionWrapper", this conservé (inc() === inst)oui (CHANGELOG 1.13.0/1.14.1)—
4bBRANCHE — Toutes les fonctions own énumérables sont wrappéesL1076-1081inst.inc.name === "actionWrapper", this conservé (inc() === inst)oui (CHANGELOG 1.13.0/1.14.1)—
5Récursion dans les objets imbriquésL1081-1084inst.nested.deep.inc.name === "actionWrapper"oui (CHANGELOG 1.14.1)—
5bBRANCHE — Récursion dans les objets imbriquésL1081-1084inst.nested.deep.inc.name === "actionWrapper"oui (CHANGELOG 1.14.1)—
6Descend aussi dans les tableauxL1077createModel(() => [signal(1)]) → Array.isArray(inst) === true, inst[0].brand intactnon—
7Ne descend pas dans un objet brand (signal/computed)L1081String(inst.nested.s.brand) === "Symbol(preact-signals)", valeur intactenonc'est ce qui protège les signaux
8for…in ⇒ les méthodes de classe ne sont PAS wrappéesL1077class Foo { m(){} n = signal(1) } → inst.m.name === "m"nonprototype = non énumérable
9Les valeurs primitives sont intactesL1079-1084{ n: 5, str: "x", nil: null, arr: [1,2] } inchangénon—
10Date non touchéL1081inst.d === dnon—
11Objet cyclique → RangeError, mutation partielleL1077RangeError: Maximum call stack size exceeded, o.m.name === "actionWrapper" après le crashnon—
11bBRANCHE — Objet cyclique → RangeError, mutation partielleL1077RangeError: Maximum call stack size exceeded, o.m.name === "actionWrapper" après le crashnon—
12Un getter est évalué une seule foisL1077-1080gets === 1, la valeur stockée est un signal ("brand" in inst.v)nonle getter devient une propriété de données
13Effet créé dans un getter : non possédéL1109 après L1106["from getter",|dispose|] puis riennonwrapInAction est hors du scope de capture
14Arguments transmis, this de la factory = sloppy global / undefinedL1098args: [1,2] ; selfType: "object" (CJS) vs undefined (ESM)nonmodelFactory(...args) est un appel simple
15Aucune validation à l'exécutionL1001-1009 (types seuls){ anything: 42, fn: () => 1 } accepté, fn wrappéoui (CHANGELOG 1.13.0 « TypeScript validates »)la validation est statique uniquement
16Factory qui throw : les effets capturés sont perdusL1099-1104["leaked","caught:factory boom",|dispose|] : l'effet leaké survit à tout disposenoncapturedEffects = undefined
17Factory imbriquée qui throw : le parent ne récupère rienL1099-1104["outer-leak","inner-leak","caught:inner boom"]non—
18Effets de la factory possédés et disposésL1096-1119["e:0","e:1",|dispose|], s=2 sans runoui (README, CHANGELOG 1.13.0)—
19Effets d'un modèle imbriqué remontés au parentL1064-1073dispose du parent → les cleanups interne et externe tournentnonprevCapturedEffects.concat(capturedEffects)
20Modèle imbriqué créé dans untracked : le parent ne possède pas ses effetsL1057-1074["inner-run","outer-run"], seul le parent est disposénon—
21Effets créés dans un batch() : possédésL95-106 (n'effle pas capturedEffects)effet de la factory disposénonbatch ≠ untracked
22Effect créé dans un computé de la factory : non possédé (évalué après la construction)L902-905["from computed 0","from computed 1",|dispose|,"from computed 2"]non—
23Effect créé dans un cleanup de dispose : non possédé, fuitL1111-1119["run","cleanup","from-cleanup"]non—
24model[Symbol.dispose] est une actionL1111["run 0","ping","cleanup 0","run 1",|dispose|,"cleanup 1"] : le cleanup du re-run précède le body, celui du dispose est le derniernon—
25Dispose idempotentL11182 appels → 1 seul cleanupnonmodelEffects = undefined
26Un cleanup qui throw interrompt les disposes suivantsL1113-1115["cleanup1","cleanup2 throws"], err: "m boom", cleanup3 jamais lancénonboucle for sans try
27Un effet déjà disposé ne rejoue pas son cleanupL945-951["cleanup",|model dispose|]non—
28model[Symbol.dispose] n'est pas énumérableL1111Object.keys(inst) = clés du modèle seul ; Object.getOwnPropertySymbols(inst) = ["Symbol(Symbol.dispose)"]non—
29Écrase un Symbol.dispose fourni par l'utilisateurL1111log vide : le dispose custom est perdu, silencieusementnon—
30Utilisable avec usingL1111["body","cleanup"]oui (README)—
31Deux instances : captures indépendantesL1092["e1:1","e2:2","e1:10","e2:20"], dispose de A sans effet sur Bnon—
32Signal partagé entre deux modèles : chaque modèle possède son effectL1096-1119["e:0","e:0","e:1","e:1","e:2"]non—
33Un effet d'un modèle qui s'inscrit sur un signal externe reste possédéL902-905dispose → plus de "sub:1"non—
34Factory qui renvoie une primitiveL1098,L1109-1111avec new : le retour primitif est ignoré, l'instance est un objet vide {} (Object.keys === [], [Symbol.dispose] === undefined) — l'affectation est un no-op silencieux. Avec null : TypeError: Cannot set properties of null dans les deux modes. ESM strict + primitif : TypeError: Cannot create property 'Symbol(Symbol.dispose)' on number '42'nondépend de la strictness et de new

Signal.prototype.subscribe(fn) ​

Implémentation : un effect nommé "sub" (L427-435) qui lit this.value puis appelle fn(value) sous untracked. Le retour est le dispositeur de l'effect.

#ComportementLocalisation src/index.tsObservé (résultat du probe)Documenté ?Quirk figé
1Appel immédiat et synchrone à l'abonnementL428-434["got 0","after subscribe","got 1"]nonle premier appel précède la ligne suivante
2Le retour est le disposeur de l'effect, pas une fonction d'abonnement dédiéeL428unName === "bound ", unLength === 0, Object.keys(un) === [], Symbol.dispose in un === truenon—
3Re-notifié à chaque changement de valeurL937-943["got 0","got 1"]non—
4Après désabonnement : plus rienL945-951s=2 sans lognon—
5Double désabonnement sans effetL945-951["v:0"]nonidempotent
6Le callback n'est pas suiviL431b=1;b=2 ne notifient pas ; le a=5 suivant lit b === 2oui (CHANGELOG 1.2.0 « subscribe unexpectedly tracking »)—
7Les écritures du callback sont batchéesL431["sub 0 b now 0","sub 1 b now 2"]non—
8Exception au premier appel : subscribe throw, aucun retourL971-976{"threw":"sub boom","ran":1}nonmême chemin que effect()
9Exception plus tard : remontée depuis l'écritureL65-70["sub:0","sub:1","caught:later boom","sub:2"] — l'effet survitnonasymétrie 1ᵉʳ run / re-run
10this du callback ≠ l'effetL431typeof this === "object", this instanceof Effect === false (CJS : globalThis; ESM : undefined)noncontraire au callback d'effect
11Le callback reçoit exactement 1 argumentL431arguments.length === 1non—
12Effet interne nommé "sub"L433s._targets._target.name === "sub" (internes)nonseule voie pour observer EffectOptions.name en pratique
13subscribe dans un effect n'ajoute pas la source à l'effect parentL431["sub:0","sub:0"] : l'effect ne re-tourne pas sur anon—
14subscribe dans un computé : l'effect interne suit la sourceL428-434["sub0","sub1"]non—
15subscribe sous untracked fonctionne normalementL431["v0","v1"]non—
16subscribe dans une factory de modèle : l'effect est possédéL902-905dispose du modèle → s=1 ne notifie plusnon—
17Ordre avec plusieurs abonnésL385-399,L937-943["1:0","2:0","1:1","2:1","2:2"]nonordre de création
18Utilisable avec usingL980["v0","body","v1"], s=2 après le blocnon—

Conversions : toString, toJSON, valueOf, brand ​

#ComportementLocalisation src/index.tsObservé (résultat du probe)Documenté ?Quirk figé
1toString() = this.value + ""L441-443signal(42).toString() === "42", signal(10n) === "10", signal(null) === "null", signal({a:1}) === "[object Object]"nonconcaténation JS, pas de formatage
2toString() sur un Symbol throwL441-443TypeError: Cannot convert a Symbol value to a stringnonle message vient de V8
3Aucun Symbol.toPrimitiveL441-447signal(1)[Symbol.toPrimitive] === undefinednon"${s}" passe par toString ; s * 2 par valueOf
4toString() suit la dépendanceL442dans un effect, 2 runsnonpeek() ne le ferait pas
5valueOf() = this.valueL437-439s * 2 === 14, s + "" === "7"oui (CHANGELOG 1.2.0)—
6valueOf() suit la dépendanceL4382 runsnon—
7toJSON() = this.valueL445-447signal({a:1}).toJSON() = l'objet ; JSON.stringify({s: signal(5)}) === '{"s":5}'oui (CHANGELOG 1.3.0)—
8toJSON() suit la dépendanceL4462 runsnon—
9Les conversions propagent l'erreur d'un computedL437-447,L761-763toString/valueOf/toJSON/peek throw "boom"nonaucune n'untracked
10toString/valueOf/toJSON/peek sont des méthodes de prototype énumérablesL437-451descripteurs {writable:true, enumerable:true, configurable:true}non—
11brand = Symbol.for("preact-signals") sur Signal.prototypeL3,L379typeof s.brand === "symbol", String(...) === "Symbol(preact-signals)", identique sur computedoui (CHANGELOG 1.5.0)—
12brand est énumérable et inscriptibleL379descripteur {writable:true, enumerable:true, configurable:true} ; s.brand = "x" fonctionnenonun signal peut perdre son identité
13brand est hérité, pas une prop d'instanceL379Object.getOwnPropertyNames(signal(1)) ne contient pas brandnon—
14JSON.stringify(signal) renvoie la valeur, pas l'objetL445-447"5" ; imbriqué : JSON.stringify({a:[signal(1),{b:signal(2)}]}) = '{"a":[1,{"b":2}]}'nonrécursion sans garde
15s + "" suit la dépendanceL437-443dans un effect, 2 runsnonpasse par valueOf puis toString

dispose et Symbol.dispose ​

#ComportementLocalisation src/index.tsObservé (résultat du probe)Documenté ?Quirk figé
1effect() retourne _dispose.bind(effect)L979{type:"function", name:"bound ", length:0}oui (README)—
2Symbol.dispose est la même fonction que le dispositeurL980d[Symbol.dispose] === d, Symbol.dispose in d === trueoui (CHANGELOG 1.11.0)—
3using déclenche le cleanupL980["run","body","cleanup"]oui (CHANGELOG 1.11.0)—
4subscribe renvoie aussi un disposeur Symbol.disposeL428hasSymbolDispose: truenon—
5Symbol.dispose absent ⇒ la clé devient la chaîne "undefined"L980Sur le bundle réel dans un realm où Symbol.dispose est absent : Object.getOwnPropertyNames(d) = ["length","name","undefined"], Object.getOwnPropertySymbols(d) = [], typeof d["undefined"] === "function". Même pour un modèle : ["a","m","undefined"].nonSymbol.dispose est non-configurable sur Node 24 ; vérifié en remplaçant le global Symbol du bundle, et l'équivalent ({})[undefined] = f donne bien Object.getOwnPropertyNames(o) === ["undefined"]
6dispose() est idempotentL945-9553 appels → 1 cleanupnon—
7dispose() pendant le run : cleanup immédiatL848-850["run:0","cleanup:0"]oui (CHANGELOG 1.2.0)passe par endEffect
8dispose() d'un effet en file : sauté au flushL62["d1:0","d2:0","d1:1"]non—
9dispose() détache toutes les dépendancesL826-838s._targets === undefinedoui (README)—
10Effect.prototype.dispose et Effect.prototype._dispose existent tous deuxL945-955Object.keys(Effect.prototype) = ["_callback","_start","_notify","_dispose","dispose"]nonAPI interne exportée
11model[Symbol.dispose] est une actionL1111batchénon—
12model[Symbol.dispose] écrase un dispose utilisateurL1111silencieuxnon—
13Le disposeur d'un modèle est un action, donc this fonctionne aussiL991,L1111inst[Symbol.dispose].name === "actionWrapper"non—
14dispose() d'un modèle avec cleanup qui throw s'arrêteL1113-1115cleanup3 jamais exécuténon—

Vérification des 15 pistes initiales ​

#PisteVerdictPreuve / correction
1NaN notifie car l'égalité est !== ; 0 et -0 ne notifient pas✅ confirmées=NaN → 2 runs, _version 1 ; 0→-0 et -0→0 → 1 run, _version 0. Précision : -0 → 0 conserve -0 comme valeur (l'écriture est ignorée), alors que 0 → -0 garde 0.
2Le flush est synchrone : l'effet s'exécute avant la fin de l'instruction✅ confirmée["e:0","before","e:1","after"]. Idem en ESM et en CJS.
3batch imbriqué = simple appel de fn, sans comptabilité supplémentaire ; seul le batch externe flush⚠️ partiellement fausseVrai pour le flush et la valeur de retour. Faux pour la « comptabilité » : le batch() imbriqué ne fait pas startBatch, mais les setters internes font toujours startBatch/endBatch ; c'est batchDepth > 0 qui diffère le flush, et une exception dans le batch imbriqué ne passe pas par try/finally (flush plus tard, au batch externe).
4Première exception mémorisée, drain complet, re-throw en fin de batch✅ confirmée, avec 2 nuances(a) « première » = première dans l'ordre de flush (LIFO) : ["A","B","B","A","caught:EB"]. (b) L'erreur d'effet écrase l'erreur du corps du batch : ["EFFECT"].
5Erreur au premier run : l'effect est disposé, l'erreur propagée✅ confirmée{runs:1, dUndefined:true, targets:true, runsAfter:1} — et effect() ne retourne aucun disposeur. watched puis unwatched sont déclenchés.
6Erreur dans un computed : stockée dans la valeur, re-throwée à la lecture de .value✅ confirmée, avec 3 nuances(a) peek/toString/valueOf/toJSON propagent aussi. (b) Le corps n'est ré-évalué qu'au prochain changement de dépendance. (c) L'effet qui la lit est disposé au premier run.
7Cycle détecté à batchIteration > 100 (sur le setter ET dans un computed lu pendant son propre calcul)⚠️ confusion de deux mécanismesSetter : batchIteration > 100 → Error: Cycle detected après 102 runs d'effet (a === 101). Computé : le getter (L753) teste le flag RUNNING, pas le compteur — il throw immédiatement, même hors batch, dès la première lecture qui référence le computé en cours de calcul (auto-cycle), ou dès la relecture après invalidation (cycle indirect). Seuls le setter et l'auto-notification d'un effet sont pilotés par batchIteration.
8subscribe notifie immédiatement, de façon synchrone, à l'abonnement✅ confirmée["got 0","after subscribe","got 1"].
9watched/unwatched au premier/dernier abonné seulement, dans un untracked✅ confirmée, avec un ordre["a+","b+","a-","b-"] pour 2 signaux ; pour un computed : ["c:watched","a:watched","b:watched"] puis ["c:unwatched","a:unwatched","b:unwatched"]. Ils sont bien sous untracked (les lectures qu'ils font n'abonnent pas).
10Computed.prototype est une instance de Signal : for..in et Object.keys exposent des props d'instance en plus✅ confirméeObject.keys(computed) = 12 clés d'instance ; for…in = 22 clés. Bonus non prévu : c'est un singleton partagé, et lire .value dessus throw TypeError: this._fn is not a function et l'empoisonne (_flags 16, 15 clés).
11toString() lève sur une valeur Symbol ; pas de Symbol.toPrimitive✅ confirméeTypeError: Cannot convert a Symbol value to a string ; s[Symbol.toPrimitive] === undefined.
12createModel mute en place, instanceof faux, appelable sans new, getter get invoqué une fois, aucune validation✅ confirméeinst === shared et shared.inc.name devient "actionWrapper" ; isM: false, ctorName: "Object" ; M(1).s.value === 1 ; gets === 1 ; {anything: 42} accepté. Nuance : la mutation en place est partielle si le modèle est cyclique (RangeError).
13Effets créés dans un untracked() ou une action() pendant une factory : non possédés✅ confirméeuntracked : ["untracked e:0","owned e:0",|dispose|,"untracked e:1"]. action : l'effet survit aussi. Documenté dans le CHANGELOG 1.14.4 et le JSDoc L118-121, pas dans le README.
14Symbol.dispose absent ⇒ clé "undefined"✅ confirméeVia le bundle réel dans un realm sans Symbol.dispose : Object.getOwnPropertyNames(d) = ["length","name","undefined"], d["undefined"] est la fonction. Idem pour model.
15batch avec auto-écriture : effet qui se modifie lui-même, effet qui en modifie un autre, ordre dans un graphe à 3 niveaux✅ confirméeSoi-même : ["run:0","run:1","run:2","run:3"] ; croisé : ["d2:0","d1:0","d1:1","d2:1","d1:1"] ; 3 niveaux (c1→c2→e1/e2) : ["c1:1","c2:2","e1:3","e2:3"] — les computeds se rafraîchissent bottom-up puis les effets, dans l'ordre inverse de notification. Ping-pong non borné : Cycle detected après 52/51 runs (2 effets) ou 35/35/34 (3 effets).

Pistes hors des 15, découvertes en cours de route ​

Voir la section Zones non documentées (60 entrées). Les plus structurantes pour la suite de tests :

  • l'ordre de flush (LIFO sur la pile de notification) et la règle BFS entre itérations ;
  • l'erreur d'effet qui écrase l'erreur du corps du batch ;
  • le seuil exact de 102 runs ;
  • Computed.prototype comme singleton empoisonnable ;
  • wrapInAction et ses 6 trous (for…in, classes, cycles, brand, getters, primitives) ;
  • la remontée des effets d'un modèle imbriqué vers le parent ;
  • la fuite des effets à la construction quand la factory throw ;
  • this en effect (instance Effect) vs this en subscribe/action/factory (strictness du consommateur) ;
  • l'absence de setter sur computed.value (no-op sloppy, TypeError strict) ;
  • le double mécanisme de détection de cycle ;
  • le fast-forward 1.14.4 et ses 3 scénarios distincts.