@signaltree/core 7.1.0 → 7.1.2

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/README.md CHANGED
@@ -36,6 +36,26 @@ import { batching } from '@signaltree/core/enhancers/batching';
36
36
  - Core + batching: ~9.3 KB gzipped (barrel vs subpath: identical)
37
37
  - Unused enhancers: **automatically excluded** by tree-shaking
38
38
 
39
+ ### Marker Tree-Shaking (Self-Registering)
40
+
41
+ Built-in markers (`entityMap()`, `status()`, `stored()`) are **self-registering** - they only add their processor code when you actually use them:
42
+
43
+ ```ts
44
+ // ✅ Only status() code is bundled (entityMap and stored tree-shaken out)
45
+ import { signalTree, status } from '@signaltree/core';
46
+ const tree = signalTree({ loadState: status() });
47
+
48
+ // ✅ Minimal bundle - no marker code included
49
+ import { signalTree } from '@signaltree/core';
50
+ const tree = signalTree({ count: 0 });
51
+ ```
52
+
53
+ **How it works:**
54
+
55
+ - Each marker factory (`status()`, `stored()`, `entityMap()`) registers its processor on first call
56
+ - If you never call a marker factory, its code is completely eliminated
57
+ - Zero import-time side effects - registration is lazy and automatic
58
+
39
59
  **When to use subpath imports:**
40
60
 
41
61
  - Older bundlers (webpack <5) with poor tree-shaking
@@ -796,6 +816,87 @@ tree.update((state) => ({
796
816
  }));
797
817
  ```
798
818
 
819
+ ### 7) Extensibility: Custom Markers & Enhancers
820
+
821
+ SignalTree is designed for extensibility. Create your own **markers** (state placeholders that materialize into specialized signals) and **enhancers** (functions that augment trees with additional capabilities).
822
+
823
+ #### Custom Marker Example
824
+
825
+ ```typescript
826
+ import { signal, Signal } from '@angular/core';
827
+ import { registerMarkerProcessor, signalTree } from '@signaltree/core';
828
+
829
+ // 1. Define marker symbol and interface
830
+ const VALIDATED_MARKER = Symbol('VALIDATED_MARKER');
831
+
832
+ interface ValidatedMarker<T> {
833
+ [VALIDATED_MARKER]: true;
834
+ defaultValue: T;
835
+ validator: (value: T) => string | null;
836
+ }
837
+
838
+ // 2. Create marker factory
839
+ function validated<T>(defaultValue: T, validator: (value: T) => string | null): ValidatedMarker<T> {
840
+ return { [VALIDATED_MARKER]: true, defaultValue, validator };
841
+ }
842
+
843
+ // 3. Type guard
844
+ function isValidatedMarker(value: unknown): value is ValidatedMarker<unknown> {
845
+ return Boolean(value && typeof value === 'object' && (value as any)[VALIDATED_MARKER] === true);
846
+ }
847
+
848
+ // 4. Register materializer (call once at app startup)
849
+ registerMarkerProcessor(isValidatedMarker, (marker) => {
850
+ const valueSignal = signal(marker.defaultValue);
851
+ const errorSignal = signal<string | null>(marker.validator(marker.defaultValue));
852
+ return {
853
+ get: () => valueSignal(),
854
+ set: (v: any) => {
855
+ valueSignal.set(v);
856
+ errorSignal.set(marker.validator(v));
857
+ },
858
+ error: errorSignal.asReadonly(),
859
+ isValid: () => errorSignal() === null,
860
+ };
861
+ });
862
+
863
+ // 5. Usage
864
+ const tree = signalTree({
865
+ email: validated('', (v) => (v.includes('@') ? null : 'Invalid email')),
866
+ });
867
+ ```
868
+
869
+ #### Custom Enhancer Example
870
+
871
+ ```typescript
872
+ import { signal, Signal } from '@angular/core';
873
+ import type { ISignalTree } from '@signaltree/core';
874
+
875
+ interface WithLogger {
876
+ log(message: string): void;
877
+ history: Signal<string[]>;
878
+ }
879
+
880
+ function withLogger(config?: { maxHistory?: number }) {
881
+ const maxHistory = config?.maxHistory ?? 100;
882
+ return <T>(tree: ISignalTree<T>): ISignalTree<T> & WithLogger => {
883
+ const historySignal = signal<string[]>([]);
884
+ return Object.assign(tree, {
885
+ log: (msg: string) => historySignal.update((h) => [...h, `[${new Date().toLocaleTimeString()}] ${msg}`].slice(-maxHistory)),
886
+ history: historySignal.asReadonly(),
887
+ });
888
+ };
889
+ }
890
+
891
+ // Usage
892
+ const tree = signalTree({ count: 0 }).with(withLogger());
893
+ tree.log('Tree created');
894
+ ```
895
+
896
+ > 📖 **Full guide**: [Custom Markers & Enhancers](https://github.com/JBorgia/signaltree/blob/main/docs/custom-markers-enhancers.md)
897
+ >
898
+ > 📱 **Interactive demo**: [Demo App](/custom-extensions)
899
+
799
900
  ## Error handling examples
800
901
 
801
902
  ### Manual async error handling
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { signalTree } from './lib/signal-tree.js';
2
- export { ENHANCER_META, entityMap } from './lib/types.js';
2
+ export { ENHANCER_META } from './lib/types.js';
3
3
  export { isDerivedMarker } from './lib/markers/derived.js';
4
4
  export { LoadingState, isStatusMarker, status } from './lib/markers/status.js';
5
5
  export { isStoredMarker, stored } from './lib/markers/stored.js';
@@ -18,6 +18,7 @@ export { devTools, enableDevTools, fullDevTools, productionDevTools } from './en
18
18
  export { createAsyncOperation, trackAsync } from './lib/async-helpers.js';
19
19
  export { TREE_PRESETS, combinePresets, createPresetConfig, getAvailablePresets, validatePreset } from './enhancers/presets/lib/presets.js';
20
20
  export { SIGNAL_TREE_CONSTANTS, SIGNAL_TREE_MESSAGES } from './lib/constants.js';
21
+ export { entityMap } from './lib/markers/entity-map.js';
21
22
  export { deepEqual, deepEqual as equal } from './deep-equal.js';
22
23
  export { parsePath } from './parse-path.js';
23
24
  export { isBuiltInObject } from './is-built-in-object.js';
@@ -1,26 +1,32 @@
1
1
  import { isSignal } from '@angular/core';
2
- import { createEntitySignal } from '../entity-signal.js';
3
- import { createStatusSignal, isStatusMarker } from '../markers/status.js';
4
- import { createStoredSignal, isStoredMarker } from '../markers/stored.js';
5
2
  import { getPathNotifier } from '../path-notifier.js';
6
3
  import { isNodeAccessor } from '../utils.js';
7
4
 
8
- function isEntityMapMarker(value) {
9
- return Boolean(value && typeof value === 'object' && value['__isEntityMap'] === true);
10
- }
11
5
  const MARKER_PROCESSORS = [];
6
+ function isRegisteredMarker(value) {
7
+ if (value === null || typeof value !== 'object') {
8
+ return false;
9
+ }
10
+ if (Object.getOwnPropertySymbols(value).length === 0) {
11
+ return false;
12
+ }
13
+ for (const processor of MARKER_PROCESSORS) {
14
+ if (processor.check(value)) {
15
+ return true;
16
+ }
17
+ }
18
+ return false;
19
+ }
12
20
  function registerMarkerProcessor(check, create) {
21
+ const alreadyRegistered = MARKER_PROCESSORS.some(p => p.check === check);
22
+ if (alreadyRegistered) {
23
+ return;
24
+ }
13
25
  MARKER_PROCESSORS.push({
14
26
  check,
15
27
  create: create
16
28
  });
17
29
  }
18
- registerMarkerProcessor(isEntityMapMarker, (marker, notifier, path) => {
19
- const cfg = marker.__entityMapConfig ?? {};
20
- return createEntitySignal(cfg, notifier, path);
21
- });
22
- registerMarkerProcessor(isStatusMarker, marker => createStatusSignal(marker));
23
- registerMarkerProcessor(isStoredMarker, marker => createStoredSignal(marker));
24
30
  function materializeMarkers(node, notifier, path = []) {
25
31
  if (node == null) return;
26
32
  if (typeof node !== 'object' && typeof node !== 'function') return;
@@ -63,4 +69,4 @@ function materializeMarkers(node, notifier, path = []) {
63
69
  }
64
70
  }
65
71
 
66
- export { materializeMarkers, registerMarkerProcessor };
72
+ export { isRegisteredMarker, materializeMarkers, registerMarkerProcessor };
@@ -0,0 +1,20 @@
1
+ import { createEntitySignal } from '../entity-signal.js';
2
+ import { registerMarkerProcessor } from '../internals/materialize-markers.js';
3
+ import { isEntityMapMarker } from '../utils.js';
4
+
5
+ let entityMapRegistered = false;
6
+ function entityMap(config) {
7
+ if (!entityMapRegistered) {
8
+ entityMapRegistered = true;
9
+ registerMarkerProcessor(isEntityMapMarker, (marker, notifier, path) => {
10
+ const cfg = marker.__entityMapConfig ?? {};
11
+ return createEntitySignal(cfg, notifier, path);
12
+ });
13
+ }
14
+ return {
15
+ __isEntityMap: true,
16
+ __entityMapConfig: config ?? {}
17
+ };
18
+ }
19
+
20
+ export { entityMap };
@@ -1,4 +1,5 @@
1
1
  import { signal, computed } from '@angular/core';
2
+ import { registerMarkerProcessor } from '../internals/materialize-markers.js';
2
3
 
3
4
  const STATUS_MARKER = Symbol('STATUS_MARKER');
4
5
  var LoadingState;
@@ -8,7 +9,12 @@ var LoadingState;
8
9
  LoadingState["Loaded"] = "LOADED";
9
10
  LoadingState["Error"] = "ERROR";
10
11
  })(LoadingState || (LoadingState = {}));
12
+ let statusRegistered = false;
11
13
  function status(initialState = LoadingState.NotLoaded) {
14
+ if (!statusRegistered) {
15
+ statusRegistered = true;
16
+ registerMarkerProcessor(isStatusMarker, createStatusSignal);
17
+ }
12
18
  return {
13
19
  [STATUS_MARKER]: true,
14
20
  initialState
@@ -1,7 +1,13 @@
1
1
  import { signal } from '@angular/core';
2
+ import { registerMarkerProcessor } from '../internals/materialize-markers.js';
2
3
 
3
4
  const STORED_MARKER = Symbol('STORED_MARKER');
5
+ let storedRegistered = false;
4
6
  function stored(key, defaultValue, options = {}) {
7
+ if (!storedRegistered) {
8
+ storedRegistered = true;
9
+ registerMarkerProcessor(isStoredMarker, createStoredSignal);
10
+ }
5
11
  return {
6
12
  [STORED_MARKER]: true,
7
13
  key,
@@ -1,7 +1,7 @@
1
1
  import { signal, isSignal } from '@angular/core';
2
2
  import { SIGNAL_TREE_MESSAGES, SIGNAL_TREE_CONSTANTS } from './constants.js';
3
3
  import { batchScope } from './internals/batch-scope.js';
4
- import { materializeMarkers } from './internals/materialize-markers.js';
4
+ import { isRegisteredMarker, materializeMarkers } from './internals/materialize-markers.js';
5
5
  import { applyDerivedFactories } from './internals/merge-derived.js';
6
6
  import { isStatusMarker } from './markers/status.js';
7
7
  import { isStoredMarker } from './markers/stored.js';
@@ -160,6 +160,15 @@ function createSignalStore(obj, equalityFn) {
160
160
  store[key] = value;
161
161
  continue;
162
162
  }
163
+ if (isRegisteredMarker(value)) {
164
+ store[key] = value;
165
+ continue;
166
+ }
167
+ if (typeof ngDevMode === 'undefined' || ngDevMode) {
168
+ if (value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getOwnPropertySymbols(value).length > 0) {
169
+ console.warn(`SignalTree: Object at "${key}" has Symbol keys but doesn't match any ` + `registered marker processor. If this is a custom marker, ensure ` + `registerMarkerProcessor() is called BEFORE creating the tree.`);
170
+ }
171
+ }
163
172
  if (isSignal(value)) {
164
173
  store[key] = value;
165
174
  continue;
package/dist/lib/types.js CHANGED
@@ -1,9 +1,3 @@
1
- function entityMap(config) {
2
- return {
3
- __isEntityMap: true,
4
- __entityMapConfig: config ?? {}
5
- };
6
- }
7
1
  const ENHANCER_META = Symbol('signaltree:enhancer:meta');
8
2
 
9
- export { ENHANCER_META, entityMap };
3
+ export { ENHANCER_META };
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@signaltree/core",
3
- "version": "7.1.0",
3
+ "version": "7.1.2",
4
4
  "description": "Lightweight, type-safe signal-based state management for Angular. Core package providing hierarchical signal trees, basic entity management, and async actions.",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "sideEffects": false,
7
8
  "main": "./dist/index.js",
@@ -13,51 +14,7 @@
13
14
  "import": "./dist/index.js",
14
15
  "default": "./dist/index.js"
15
16
  },
16
- "./enhancers": {
17
- "types": "./src/enhancers/index.d.ts",
18
- "import": "./dist/enhancers/index.js",
19
- "default": "./dist/enhancers/index.js"
20
- },
21
- "./enhancers/batching": {
22
- "types": "./src/enhancers/batching/index.d.ts",
23
- "import": "./dist/enhancers/batching/index.js",
24
- "default": "./dist/enhancers/batching/index.js"
25
- },
26
- "./enhancers/middleware": {
27
- "types": "./src/enhancers/middleware/index.d.ts",
28
- "import": "./dist/enhancers/middleware/index.js",
29
- "default": "./dist/enhancers/middleware/index.js"
30
- },
31
- "./enhancers/memoization": {
32
- "types": "./src/enhancers/memoization/index.d.ts",
33
- "import": "./dist/enhancers/memoization/index.js",
34
- "default": "./dist/enhancers/memoization/index.js"
35
- },
36
- "./enhancers/time-travel": {
37
- "types": "./src/enhancers/time-travel/index.d.ts",
38
- "import": "./dist/enhancers/time-travel/index.js",
39
- "default": "./dist/enhancers/time-travel/index.js"
40
- },
41
- "./enhancers/devtools": {
42
- "types": "./src/enhancers/devtools/index.d.ts",
43
- "import": "./dist/enhancers/devtools/index.js",
44
- "default": "./dist/enhancers/devtools/index.js"
45
- },
46
- "./enhancers/presets": {
47
- "types": "./src/enhancers/presets/index.d.ts",
48
- "import": "./dist/enhancers/presets/index.js",
49
- "default": "./dist/enhancers/presets/index.js"
50
- },
51
- "./enhancers/entities": {
52
- "types": "./src/enhancers/entities/index.d.ts",
53
- "import": "./dist/enhancers/entities/index.js",
54
- "default": "./dist/enhancers/entities/index.js"
55
- },
56
- "./enhancers/serialization": {
57
- "types": "./src/enhancers/serialization/index.d.ts",
58
- "import": "./dist/enhancers/serialization/index.js",
59
- "default": "./dist/enhancers/serialization/index.js"
60
- }
17
+ "./package.json": "./package.json"
61
18
  },
62
19
  "peerDependencies": {
63
20
  "@angular/core": "^20.0.0",
@@ -1,4 +1,5 @@
1
1
  import { PathNotifier } from '../path-notifier';
2
+ export declare function isRegisteredMarker(value: unknown): boolean;
2
3
  export declare function registerMarkerProcessor<T, R>(check: (value: unknown) => value is T, create: (marker: T, notifier: PathNotifier, path: string) => R): void;
3
4
  export declare function materializeMarkers(node: unknown, notifier?: PathNotifier, path?: string[]): void;
4
5
  export declare function hasMarkers(node: unknown, visited?: WeakSet<object>): boolean;
@@ -0,0 +1,4 @@
1
+ import type { EntityConfig, EntityMapMarker } from '../types';
2
+ export declare function entityMap<E, K extends string | number = E extends {
3
+ id: infer I extends string | number;
4
+ } ? I : string>(config?: EntityConfig<E, K>): EntityMapMarker<E, K>;
@@ -153,9 +153,7 @@ export interface EntityMapMarker<E, K extends string | number> {
153
153
  readonly __isEntityMap: true;
154
154
  readonly __entityMapConfig?: EntityConfig<E, K>;
155
155
  }
156
- export declare function entityMap<E, K extends string | number = E extends {
157
- id: infer I extends string | number;
158
- } ? I : string>(config?: EntityConfig<E, K>): EntityMapMarker<E, K>;
156
+ export { entityMap } from './markers/entity-map';
159
157
  export interface MutationOptions {
160
158
  onError?: (error: Error) => void;
161
159
  }
@@ -300,4 +298,3 @@ export type MinimalSignalTree<T> = ISignalTree<T> & EffectsMethods<T>;
300
298
  export type SignalTree<T> = ISignalTree<T> & TreeNode<T>;
301
299
  export type SignalTreeBase<T> = ISignalTree<T> & TreeNode<T>;
302
300
  export declare function isSignalTree<T>(value: unknown): value is ISignalTree<T>;
303
- export {};