dalila 1.3.1 → 1.3.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/dist/context/auto-scope.d.ts +65 -49
- package/dist/context/auto-scope.js +208 -88
- package/dist/context/context.d.ts +106 -2
- package/dist/context/context.js +245 -28
- package/dist/context/index.d.ts +2 -3
- package/dist/context/index.js +2 -3
- package/dist/context/raw.d.ts +1 -1
- package/dist/context/raw.js +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/match.js +9 -3
- package/dist/core/query.js +46 -13
- package/dist/core/resource.d.ts +110 -0
- package/dist/core/resource.js +280 -39
- package/dist/core/scheduler.d.ts +31 -0
- package/dist/core/scheduler.js +26 -13
- package/dist/core/scope.d.ts +17 -1
- package/dist/core/scope.js +62 -7
- package/dist/core/signal.d.ts +22 -0
- package/dist/core/signal.js +86 -0
- package/dist/core/when.js +9 -3
- package/package.json +1 -1
package/dist/context/context.js
CHANGED
|
@@ -1,66 +1,283 @@
|
|
|
1
|
-
import { getCurrentScope } from
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { getCurrentScope } from "../core/scope.js";
|
|
2
|
+
import { isInDevMode } from "../core/dev.js";
|
|
3
|
+
/**
|
|
4
|
+
* Create a new context token.
|
|
5
|
+
*
|
|
6
|
+
* Notes:
|
|
7
|
+
* - Tokens are identity-based: the same token instance is the key.
|
|
8
|
+
* - `name` is for developer-facing errors and debugging only.
|
|
9
|
+
* - `defaultValue` is returned by inject/tryInject when no provider is found.
|
|
10
|
+
*/
|
|
11
|
+
export function createContext(name, defaultValue) {
|
|
12
|
+
return { name, defaultValue };
|
|
4
13
|
}
|
|
14
|
+
let globalRevision = 0;
|
|
15
|
+
let warnedDeepHierarchy = false;
|
|
16
|
+
/**
|
|
17
|
+
* Configurable deep hierarchy warning threshold.
|
|
18
|
+
*/
|
|
19
|
+
let deepHierarchyWarnDepth = 50;
|
|
20
|
+
/**
|
|
21
|
+
* Configure the depth at which context lookup warns about deep hierarchies.
|
|
22
|
+
* Set to Infinity to disable the warning.
|
|
23
|
+
*/
|
|
24
|
+
export function setDeepHierarchyWarnDepth(depth) {
|
|
25
|
+
deepHierarchyWarnDepth = depth;
|
|
26
|
+
}
|
|
27
|
+
function bumpRevision() {
|
|
28
|
+
globalRevision++;
|
|
29
|
+
}
|
|
30
|
+
function maybeWarnDeepHierarchy(depth) {
|
|
31
|
+
if (!isInDevMode())
|
|
32
|
+
return;
|
|
33
|
+
if (warnedDeepHierarchy)
|
|
34
|
+
return;
|
|
35
|
+
if (depth < deepHierarchyWarnDepth)
|
|
36
|
+
return;
|
|
37
|
+
warnedDeepHierarchy = true;
|
|
38
|
+
console.warn(`[Dalila] Context lookup traversed ${depth} parent scopes. ` +
|
|
39
|
+
`Consider flattening scope hierarchy or caching static contexts.`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Per-scope registry that stores context values and links to a parent registry.
|
|
43
|
+
*
|
|
44
|
+
* Lookup:
|
|
45
|
+
* - `get()` checks current registry first.
|
|
46
|
+
* - If not found, it delegates to the parent registry (if any).
|
|
47
|
+
*/
|
|
5
48
|
class ContextRegistry {
|
|
6
|
-
constructor(parent) {
|
|
49
|
+
constructor(ownerScope, parent) {
|
|
7
50
|
this.contexts = new Map();
|
|
51
|
+
this.resolveCache = new Map();
|
|
52
|
+
this.ownerScope = ownerScope;
|
|
8
53
|
this.parent = parent;
|
|
9
54
|
}
|
|
10
55
|
set(token, value) {
|
|
11
56
|
this.contexts.set(token, { token, value });
|
|
57
|
+
this.resolveCache.delete(token);
|
|
12
58
|
}
|
|
13
59
|
get(token) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
60
|
+
const res = this.resolve(token);
|
|
61
|
+
return res ? res.value : undefined;
|
|
62
|
+
}
|
|
63
|
+
resolve(token) {
|
|
64
|
+
const cached = this.resolveCache.get(token);
|
|
65
|
+
if (cached && cached.rev === globalRevision)
|
|
66
|
+
return cached.res;
|
|
67
|
+
const hit = this.contexts.get(token);
|
|
68
|
+
if (hit !== undefined) {
|
|
69
|
+
const res = { value: hit.value, ownerScope: this.ownerScope, depth: 0 };
|
|
70
|
+
this.resolveCache.set(token, { rev: globalRevision, res });
|
|
71
|
+
return res;
|
|
17
72
|
}
|
|
18
73
|
if (this.parent) {
|
|
19
|
-
|
|
74
|
+
const parentRes = this.parent.resolve(token);
|
|
75
|
+
if (parentRes) {
|
|
76
|
+
const res = {
|
|
77
|
+
value: parentRes.value,
|
|
78
|
+
ownerScope: parentRes.ownerScope,
|
|
79
|
+
depth: parentRes.depth + 1,
|
|
80
|
+
};
|
|
81
|
+
this.resolveCache.set(token, { rev: globalRevision, res });
|
|
82
|
+
return res;
|
|
83
|
+
}
|
|
20
84
|
}
|
|
85
|
+
this.resolveCache.set(token, { rev: globalRevision, res: undefined });
|
|
21
86
|
return undefined;
|
|
22
87
|
}
|
|
88
|
+
listTokens(maxPerLevel) {
|
|
89
|
+
const tokens = [];
|
|
90
|
+
for (const entry of this.contexts.values()) {
|
|
91
|
+
tokens.push(entry.token);
|
|
92
|
+
if (maxPerLevel != null && tokens.length >= maxPerLevel)
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
return tokens;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Release references eagerly.
|
|
99
|
+
*
|
|
100
|
+
* Safety:
|
|
101
|
+
* - Scopes in Dalila cascade: disposing a parent scope disposes children,
|
|
102
|
+
* so no child scope should outlive a parent registry.
|
|
103
|
+
*/
|
|
23
104
|
clear() {
|
|
105
|
+
bumpRevision();
|
|
24
106
|
this.contexts.clear();
|
|
25
107
|
this.parent = undefined;
|
|
108
|
+
this.resolveCache.clear();
|
|
26
109
|
}
|
|
27
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Registry storage keyed by Scope.
|
|
113
|
+
*
|
|
114
|
+
* Why WeakMap:
|
|
115
|
+
* - Avoid keeping scopes alive through registry bookkeeping.
|
|
116
|
+
*/
|
|
28
117
|
const scopeRegistries = new WeakMap();
|
|
118
|
+
/**
|
|
119
|
+
* Get (or lazily create) the registry for a given scope.
|
|
120
|
+
*
|
|
121
|
+
* Key detail:
|
|
122
|
+
* - Parent linkage is derived from `scope.parent` (captured at createScope time),
|
|
123
|
+
* not from `getCurrentScope()`. This makes the hierarchy stable and explicit.
|
|
124
|
+
*
|
|
125
|
+
* Cleanup:
|
|
126
|
+
* - When the scope is disposed, we clear the registry and remove it from the WeakMap.
|
|
127
|
+
*/
|
|
29
128
|
function getScopeRegistry(scope) {
|
|
30
129
|
let registry = scopeRegistries.get(scope);
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
130
|
+
if (registry)
|
|
131
|
+
return registry;
|
|
132
|
+
const parentScope = scope.parent;
|
|
133
|
+
const parentRegistry = parentScope ? getScopeRegistry(parentScope) : undefined;
|
|
134
|
+
registry = new ContextRegistry(scope, parentRegistry);
|
|
135
|
+
scopeRegistries.set(scope, registry);
|
|
136
|
+
const registryRef = registry;
|
|
137
|
+
scope.onCleanup(() => {
|
|
138
|
+
registryRef.clear();
|
|
139
|
+
scopeRegistries.delete(scope);
|
|
140
|
+
});
|
|
43
141
|
return registry;
|
|
44
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Provide a context value in the current scope.
|
|
145
|
+
*
|
|
146
|
+
* Rules:
|
|
147
|
+
* - Must be called inside a scope.
|
|
148
|
+
* - Overrides any existing value for the same token in this scope.
|
|
149
|
+
*/
|
|
45
150
|
export function provide(token, value) {
|
|
46
151
|
const scope = getCurrentScope();
|
|
47
152
|
if (!scope) {
|
|
48
|
-
throw new Error(
|
|
153
|
+
throw new Error("[Dalila] provide() must be called within a scope. " +
|
|
154
|
+
"Use withScope(createScope(), () => provide(...)) or the auto-scope API.");
|
|
49
155
|
}
|
|
50
156
|
const registry = getScopeRegistry(scope);
|
|
157
|
+
bumpRevision();
|
|
51
158
|
registry.set(token, value);
|
|
52
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Inject a context value from the current scope hierarchy.
|
|
162
|
+
*
|
|
163
|
+
* Rules:
|
|
164
|
+
* - Must be called inside a scope.
|
|
165
|
+
* - Walks up the parent chain until it finds the token.
|
|
166
|
+
* - If not found and token has a defaultValue, returns that.
|
|
167
|
+
* - Throws a descriptive error if not found and no default.
|
|
168
|
+
*/
|
|
53
169
|
export function inject(token) {
|
|
54
170
|
const scope = getCurrentScope();
|
|
55
171
|
if (!scope) {
|
|
56
|
-
throw new Error(
|
|
172
|
+
throw new Error("[Dalila] inject() must be called within a scope. " +
|
|
173
|
+
"Wrap your code in withScope(...) or use the auto-scope API.");
|
|
174
|
+
}
|
|
175
|
+
const registry = getScopeRegistry(scope);
|
|
176
|
+
const res = registry.resolve(token);
|
|
177
|
+
if (res) {
|
|
178
|
+
maybeWarnDeepHierarchy(res.depth);
|
|
179
|
+
return res.value;
|
|
180
|
+
}
|
|
181
|
+
// Check for default value
|
|
182
|
+
if (token.defaultValue !== undefined) {
|
|
183
|
+
return token.defaultValue;
|
|
184
|
+
}
|
|
185
|
+
const name = token.name || "unnamed";
|
|
186
|
+
let message = `[Dalila] Context "${name}" not found in scope hierarchy.`;
|
|
187
|
+
if (isInDevMode()) {
|
|
188
|
+
const levels = debugListAvailableContexts(8);
|
|
189
|
+
if (levels.length > 0) {
|
|
190
|
+
message += `\n\nAvailable contexts by depth:\n`;
|
|
191
|
+
for (const level of levels) {
|
|
192
|
+
const names = level.tokens.map((t) => t.name).join(", ") || "(none)";
|
|
193
|
+
message += ` depth ${level.depth}: ${names}\n`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw new Error(message);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Inject a context value from the current scope hierarchy if present.
|
|
201
|
+
*
|
|
202
|
+
* Rules:
|
|
203
|
+
* - Must be called inside a scope.
|
|
204
|
+
* - Returns { found: true, value } when found.
|
|
205
|
+
* - Returns { found: false, value: undefined } when not found (or uses defaultValue if available).
|
|
206
|
+
*/
|
|
207
|
+
export function tryInject(token) {
|
|
208
|
+
const scope = getCurrentScope();
|
|
209
|
+
if (!scope) {
|
|
210
|
+
throw new Error("[Dalila] tryInject() must be called within a scope. " +
|
|
211
|
+
"Wrap your code in withScope(...) or use the auto-scope API.");
|
|
212
|
+
}
|
|
213
|
+
const registry = getScopeRegistry(scope);
|
|
214
|
+
const res = registry.resolve(token);
|
|
215
|
+
if (res) {
|
|
216
|
+
maybeWarnDeepHierarchy(res.depth);
|
|
217
|
+
return { found: true, value: res.value };
|
|
218
|
+
}
|
|
219
|
+
// Check for default value
|
|
220
|
+
if (token.defaultValue !== undefined) {
|
|
221
|
+
return { found: true, value: token.defaultValue };
|
|
222
|
+
}
|
|
223
|
+
return { found: false, value: undefined };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Inject a context value and return metadata about where it was resolved.
|
|
227
|
+
*/
|
|
228
|
+
export function injectMeta(token) {
|
|
229
|
+
const scope = getCurrentScope();
|
|
230
|
+
if (!scope) {
|
|
231
|
+
throw new Error("[Dalila] injectMeta() must be called within a scope. " +
|
|
232
|
+
"Wrap your code in withScope(...) or use the auto-scope API.");
|
|
57
233
|
}
|
|
58
|
-
// The registry.get() method already walks up the parent chain,
|
|
59
|
-
// so we only need to get the registry for the current scope
|
|
60
234
|
const registry = getScopeRegistry(scope);
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
235
|
+
const res = registry.resolve(token);
|
|
236
|
+
if (!res) {
|
|
237
|
+
const name = token.name || "unnamed";
|
|
238
|
+
let message = `[Dalila] Context "${name}" not found in scope hierarchy.`;
|
|
239
|
+
if (isInDevMode()) {
|
|
240
|
+
const levels = debugListAvailableContexts(8);
|
|
241
|
+
if (levels.length > 0) {
|
|
242
|
+
message += `\n\nAvailable contexts by depth:\n`;
|
|
243
|
+
for (const level of levels) {
|
|
244
|
+
const names = level.tokens.map((t) => t.name).join(", ") || "(none)";
|
|
245
|
+
message += ` depth ${level.depth}: ${names}\n`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
throw new Error(message);
|
|
64
250
|
}
|
|
65
|
-
|
|
251
|
+
maybeWarnDeepHierarchy(res.depth);
|
|
252
|
+
return { value: res.value, ownerScope: res.ownerScope, depth: res.depth };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Debug helper: list available context tokens per depth.
|
|
256
|
+
*/
|
|
257
|
+
export function debugListAvailableContexts(maxPerLevel) {
|
|
258
|
+
const scope = getCurrentScope();
|
|
259
|
+
if (!scope)
|
|
260
|
+
return [];
|
|
261
|
+
const levels = [];
|
|
262
|
+
let depth = 0;
|
|
263
|
+
let current = scope;
|
|
264
|
+
while (current) {
|
|
265
|
+
const registry = scopeRegistries.get(current);
|
|
266
|
+
const tokens = registry
|
|
267
|
+
? registry.listTokens(maxPerLevel).map((token) => ({
|
|
268
|
+
name: token.name || "unnamed",
|
|
269
|
+
token,
|
|
270
|
+
}))
|
|
271
|
+
: [];
|
|
272
|
+
levels.push({ depth, tokens });
|
|
273
|
+
current = current.parent;
|
|
274
|
+
depth++;
|
|
275
|
+
}
|
|
276
|
+
return levels;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Alias for debugListAvailableContexts (public helper name).
|
|
280
|
+
*/
|
|
281
|
+
export function listAvailableContexts(maxPerLevel) {
|
|
282
|
+
return debugListAvailableContexts(maxPerLevel);
|
|
66
283
|
}
|
package/dist/context/index.d.ts
CHANGED
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
export { createContext, type ContextToken } from "./context.js";
|
|
2
|
-
export { provide, inject, scope, createProvider, getGlobalScope, resetGlobalScope, } from "./auto-scope.js";
|
|
3
|
-
export { provide as provideGlobalSafe, inject as injectGlobalSafe } from "./auto-scope.js";
|
|
1
|
+
export { createContext, setDeepHierarchyWarnDepth, listAvailableContexts, type ContextToken, type TryInjectResult, } from "./context.js";
|
|
2
|
+
export { provide, inject, tryInject, scope, createProvider, provideGlobal, injectGlobal, setAutoScopePolicy, hasGlobalScope, getGlobalScope, resetGlobalScope, } from "./auto-scope.js";
|
package/dist/context/index.js
CHANGED
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
export { createContext } from "./context.js";
|
|
2
|
-
export { provide, inject, scope, createProvider, getGlobalScope, resetGlobalScope, } from "./auto-scope.js";
|
|
3
|
-
export { provide as provideGlobalSafe, inject as injectGlobalSafe } from "./auto-scope.js";
|
|
1
|
+
export { createContext, setDeepHierarchyWarnDepth, listAvailableContexts, } from "./context.js";
|
|
2
|
+
export { provide, inject, tryInject, scope, createProvider, provideGlobal, injectGlobal, setAutoScopePolicy, hasGlobalScope, getGlobalScope, resetGlobalScope, } from "./auto-scope.js";
|
package/dist/context/raw.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { createContext, type ContextToken } from "./context.js";
|
|
2
|
-
export { provide, inject } from "./context.js";
|
|
2
|
+
export { provide, inject, tryInject, injectMeta, debugListAvailableContexts, listAvailableContexts, } from "./context.js";
|
package/dist/context/raw.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { createContext } from "./context.js";
|
|
2
|
-
export { provide, inject } from "./context.js";
|
|
2
|
+
export { provide, inject, tryInject, injectMeta, debugListAvailableContexts, listAvailableContexts, } from "./context.js";
|
package/dist/core/index.d.ts
CHANGED
|
@@ -11,4 +11,4 @@ export * from "./resource.js";
|
|
|
11
11
|
export * from "./query.js";
|
|
12
12
|
export * from "./mutation.js";
|
|
13
13
|
export * from "./store.js";
|
|
14
|
-
export { batch, measure, mutate } from "./scheduler.js";
|
|
14
|
+
export { batch, measure, mutate, configureScheduler, getSchedulerConfig } from "./scheduler.js";
|
package/dist/core/index.js
CHANGED
|
@@ -11,4 +11,4 @@ export * from "./resource.js";
|
|
|
11
11
|
export * from "./query.js";
|
|
12
12
|
export * from "./mutation.js";
|
|
13
13
|
export * from "./store.js";
|
|
14
|
-
export { batch, measure, mutate } from "./scheduler.js";
|
|
14
|
+
export { batch, measure, mutate, configureScheduler, getSchedulerConfig } from "./scheduler.js";
|
package/dist/core/match.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { effect } from "./signal.js";
|
|
2
|
-
import { createScope, getCurrentScope, withScope } from "./scope.js";
|
|
2
|
+
import { createScope, getCurrentScope, isScopeDisposed, withScope } from "./scope.js";
|
|
3
3
|
import { scheduleMicrotask } from "./scheduler.js";
|
|
4
4
|
/**
|
|
5
5
|
* Multi-branch conditional DOM primitive with per-case lifetime.
|
|
@@ -35,6 +35,13 @@ export function match(value, cases) {
|
|
|
35
35
|
/** Microtask coalescing: allow only one pending swap per tick. */
|
|
36
36
|
let swapScheduled = false;
|
|
37
37
|
let pendingKey = undefined;
|
|
38
|
+
/** Parent scope captured at creation time (if any). */
|
|
39
|
+
const parentScope = getCurrentScope();
|
|
40
|
+
const resolveParentScope = () => {
|
|
41
|
+
if (!parentScope)
|
|
42
|
+
return null;
|
|
43
|
+
return isScopeDisposed(parentScope) ? null : parentScope;
|
|
44
|
+
};
|
|
38
45
|
/**
|
|
39
46
|
* Guard to prevent "orphan" microtasks from touching DOM after this match()
|
|
40
47
|
* is disposed by a parent scope.
|
|
@@ -89,7 +96,7 @@ export function match(value, cases) {
|
|
|
89
96
|
*/
|
|
90
97
|
const swap = (key) => {
|
|
91
98
|
clear();
|
|
92
|
-
const nextScope = createScope();
|
|
99
|
+
const nextScope = createScope(resolveParentScope());
|
|
93
100
|
try {
|
|
94
101
|
const fn = cases[key];
|
|
95
102
|
if (!fn) {
|
|
@@ -158,7 +165,6 @@ export function match(value, cases) {
|
|
|
158
165
|
* - dispose current case scope,
|
|
159
166
|
* - remove mounted nodes.
|
|
160
167
|
*/
|
|
161
|
-
const parentScope = getCurrentScope();
|
|
162
168
|
if (parentScope) {
|
|
163
169
|
parentScope.onCleanup(() => {
|
|
164
170
|
disposed = true;
|
package/dist/core/query.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { computed, effect } from "./signal.js";
|
|
2
|
-
import { getCurrentScope } from "./scope.js";
|
|
2
|
+
import { getCurrentScope, createScope, withScope } from "./scope.js";
|
|
3
3
|
import { key as keyBuilder, encodeKey } from "./key.js";
|
|
4
4
|
import { createCachedResource, invalidateResourceCache, invalidateResourceTag, invalidateResourceTags, } from "./resource.js";
|
|
5
5
|
import { createMutation } from "./mutation.js";
|
|
6
|
+
import { isInDevMode } from "./dev.js";
|
|
6
7
|
/**
|
|
7
8
|
* Query client (React Query-like API on top of Dalila resources).
|
|
8
9
|
*
|
|
@@ -25,9 +26,31 @@ import { createMutation } from "./mutation.js";
|
|
|
25
26
|
export function createQueryClient() {
|
|
26
27
|
function makeQuery(cfg, behavior) {
|
|
27
28
|
const scope = getCurrentScope();
|
|
29
|
+
const parentScope = scope;
|
|
28
30
|
const staleTime = cfg.staleTime ?? 0;
|
|
29
31
|
let staleTimer = null;
|
|
30
32
|
let cleanupRegistered = false;
|
|
33
|
+
let keyScope = null;
|
|
34
|
+
let keyScopeCk = null;
|
|
35
|
+
if (isInDevMode() && !parentScope && behavior.persist === false) {
|
|
36
|
+
console.warn(`[Dalila] q.query() called outside a scope. ` +
|
|
37
|
+
`It will not cache and may leak. Use within a scope or q.queryGlobal().`);
|
|
38
|
+
}
|
|
39
|
+
function ensureKeyScope(ck) {
|
|
40
|
+
if (!parentScope)
|
|
41
|
+
return null;
|
|
42
|
+
if (keyScope && keyScopeCk === ck)
|
|
43
|
+
return keyScope;
|
|
44
|
+
// cancel any pending stale timer from the previous key
|
|
45
|
+
if (staleTimer != null) {
|
|
46
|
+
clearTimeout(staleTimer);
|
|
47
|
+
staleTimer = null;
|
|
48
|
+
}
|
|
49
|
+
keyScope?.dispose();
|
|
50
|
+
keyScopeCk = ck;
|
|
51
|
+
keyScope = createScope(parentScope);
|
|
52
|
+
return keyScope;
|
|
53
|
+
}
|
|
31
54
|
/**
|
|
32
55
|
* Schedules a stale-time revalidation after success.
|
|
33
56
|
*
|
|
@@ -41,8 +64,17 @@ export function createQueryClient() {
|
|
|
41
64
|
const scheduleStaleRevalidate = (r, expectedCk) => {
|
|
42
65
|
if (staleTime <= 0)
|
|
43
66
|
return;
|
|
67
|
+
if (!scope) {
|
|
68
|
+
if (isInDevMode()) {
|
|
69
|
+
console.warn(`[Dalila] staleTime requires a scope for cleanup. ` +
|
|
70
|
+
`Run the query inside a scope or disable staleTime.`);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (encodeKey(cfg.key()) !== expectedCk)
|
|
75
|
+
return;
|
|
44
76
|
// Register cleanup once (if we have a scope).
|
|
45
|
-
if (!cleanupRegistered
|
|
77
|
+
if (!cleanupRegistered) {
|
|
46
78
|
cleanupRegistered = true;
|
|
47
79
|
scope.onCleanup(() => {
|
|
48
80
|
if (staleTimer != null)
|
|
@@ -72,6 +104,7 @@ export function createQueryClient() {
|
|
|
72
104
|
const k = cfg.key();
|
|
73
105
|
const ck = encodeKey(k);
|
|
74
106
|
let r;
|
|
107
|
+
const ks = ensureKeyScope(ck);
|
|
75
108
|
const opts = {
|
|
76
109
|
onError: cfg.onError,
|
|
77
110
|
onSuccess: (data) => {
|
|
@@ -79,11 +112,17 @@ export function createQueryClient() {
|
|
|
79
112
|
scheduleStaleRevalidate(r, ck);
|
|
80
113
|
},
|
|
81
114
|
persist: behavior.persist,
|
|
115
|
+
warnPersistWithoutTtl: behavior.warnPersistWithoutTtl,
|
|
116
|
+
fetchScope: ks ?? undefined,
|
|
82
117
|
};
|
|
83
118
|
if (cfg.initialValue !== undefined)
|
|
84
119
|
opts.initialValue = cfg.initialValue;
|
|
85
120
|
// Keyed cache entry (scope-safe unless persist is enabled).
|
|
86
|
-
|
|
121
|
+
const make = () => createCachedResource(ck, async (sig) => {
|
|
122
|
+
await Promise.resolve(); // break reactive tracking
|
|
123
|
+
return cfg.fetch(sig, k);
|
|
124
|
+
}, { ...opts, tags: cfg.tags });
|
|
125
|
+
r = ks ? withScope(ks, make) : make();
|
|
87
126
|
return r;
|
|
88
127
|
});
|
|
89
128
|
/** Convenience derived status from the underlying resource. */
|
|
@@ -98,17 +137,11 @@ export function createQueryClient() {
|
|
|
98
137
|
/** Expose the current encoded key as a computed signal. */
|
|
99
138
|
const cacheKeySig = computed(() => encodeKey(cfg.key()));
|
|
100
139
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
140
|
+
* Kick once so the initial query starts immediately.
|
|
141
|
+
* Then keep it reactive so key changes recreate the resource
|
|
142
|
+
* even if nobody reads data() / loading() / error().
|
|
104
143
|
*/
|
|
105
144
|
resource();
|
|
106
|
-
/**
|
|
107
|
-
* Key-change driver:
|
|
108
|
-
* - computed() only re-evaluates on read
|
|
109
|
-
* - this effect reads resource() so key changes will recreate the resource
|
|
110
|
-
* even if the consumer never reads data() / loading() / error()
|
|
111
|
-
*/
|
|
112
145
|
effect(() => {
|
|
113
146
|
resource();
|
|
114
147
|
});
|
|
@@ -125,7 +158,7 @@ export function createQueryClient() {
|
|
|
125
158
|
return makeQuery(cfg, { persist: false });
|
|
126
159
|
}
|
|
127
160
|
function queryGlobal(cfg) {
|
|
128
|
-
return makeQuery(cfg, { persist: true });
|
|
161
|
+
return makeQuery(cfg, { persist: true, warnPersistWithoutTtl: false });
|
|
129
162
|
}
|
|
130
163
|
function mutation(cfg) {
|
|
131
164
|
return createMutation(cfg);
|
package/dist/core/resource.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Signal } from "./signal.js";
|
|
2
|
+
import { type Scope } from "./scope.js";
|
|
2
3
|
/**
|
|
3
4
|
* ResourceOptions:
|
|
4
5
|
* - initialValue: optional initial data (null is allowed by design)
|
|
@@ -96,6 +97,40 @@ export type DepSource<D> = (() => D) | ReadonlyArray<Signal<any>> | {
|
|
|
96
97
|
* Guard: early-return resolves only if `!loading()`.
|
|
97
98
|
*/
|
|
98
99
|
export declare function createDependentResource<T, D>(fetchFn: (signal: AbortSignal, deps: D) => Promise<T>, deps: DepSource<D>, options?: ResourceOptions<T>): ResourceState<T>;
|
|
100
|
+
/**
|
|
101
|
+
* CacheEntry:
|
|
102
|
+
* A cached resource plus book-keeping for memory safety and invalidation.
|
|
103
|
+
*
|
|
104
|
+
* Fields:
|
|
105
|
+
* - createdAt/ttlMs: TTL-based expiry.
|
|
106
|
+
* - tags: tag index for invalidation.
|
|
107
|
+
* - stale: marker used by invalidation flows.
|
|
108
|
+
* - refCount: number of active scopes referencing this entry.
|
|
109
|
+
* - persist: if true, entry may live outside scopes (global cache).
|
|
110
|
+
* - cacheScope: dedicated scope that owns the underlying resource lifetime.
|
|
111
|
+
*
|
|
112
|
+
* Why cacheScope?
|
|
113
|
+
* - If a cached resource were created in the caller scope, it could be disposed
|
|
114
|
+
* prematurely when that scope ends, even though other scopes still reference it.
|
|
115
|
+
* - We isolate each cache entry in its own scope so the cache controls disposal.
|
|
116
|
+
*/
|
|
117
|
+
type CacheEntry = {
|
|
118
|
+
resource: ResourceState<any>;
|
|
119
|
+
createdAt: number;
|
|
120
|
+
ttlMs?: number;
|
|
121
|
+
tags: Set<string>;
|
|
122
|
+
stale: boolean;
|
|
123
|
+
/** Active scoped users. */
|
|
124
|
+
refCount: number;
|
|
125
|
+
/** Explicit "keep global" flag. */
|
|
126
|
+
persist: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Dedicated scope for this cache entry.
|
|
129
|
+
* The resource is created inside this scope, isolating it from caller scopes.
|
|
130
|
+
* When the entry is removed from cache, this scope is disposed.
|
|
131
|
+
*/
|
|
132
|
+
cacheScope: Scope;
|
|
133
|
+
};
|
|
99
134
|
export interface CachedResourceOptions<T> extends ResourceOptions<T> {
|
|
100
135
|
ttlMs?: number;
|
|
101
136
|
tags?: readonly string[];
|
|
@@ -109,6 +144,16 @@ export interface CachedResourceOptions<T> extends ResourceOptions<T> {
|
|
|
109
144
|
* Leave default as true to teach DX.
|
|
110
145
|
*/
|
|
111
146
|
warnIfNoScope?: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* Optional dev warning when persist is true without ttlMs.
|
|
149
|
+
* Leave default as true to teach DX.
|
|
150
|
+
*/
|
|
151
|
+
warnPersistWithoutTtl?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Optional scope to run fetchFn inside (for context lookup).
|
|
154
|
+
* Note: only the sync portion before the first await runs inside this scope.
|
|
155
|
+
*/
|
|
156
|
+
fetchScope?: Scope | null;
|
|
112
157
|
}
|
|
113
158
|
/**
|
|
114
159
|
* Global cache configuration for memory safety.
|
|
@@ -186,4 +231,69 @@ export declare function getResourceCacheKeysByTag(tag: string): string[];
|
|
|
186
231
|
* - Outside a scope, it warns and returns a normal resource (no interval).
|
|
187
232
|
*/
|
|
188
233
|
export declare function createAutoRefreshResource<T>(fetchFn: (signal: AbortSignal) => Promise<T>, refreshInterval: number, options?: ResourceOptions<T>): ResourceState<T>;
|
|
234
|
+
/**
|
|
235
|
+
* Isolated cache instance for SSR and testing.
|
|
236
|
+
*
|
|
237
|
+
* Use this when you need complete cache isolation:
|
|
238
|
+
* - SSR: each request gets its own cache
|
|
239
|
+
* - Testing: each test gets fresh state
|
|
240
|
+
*
|
|
241
|
+
* Example:
|
|
242
|
+
* ```ts
|
|
243
|
+
* const { createCachedResource, clearCache, invalidateKey, getCache } = createIsolatedCache();
|
|
244
|
+
*
|
|
245
|
+
* // Use the isolated createCachedResource instead of the global one
|
|
246
|
+
* const resource = createCachedResource('key', fetchFn);
|
|
247
|
+
*
|
|
248
|
+
* // Clean up when done
|
|
249
|
+
* clearCache();
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
export interface IsolatedCache {
|
|
253
|
+
/**
|
|
254
|
+
* Create a cached resource using this isolated cache.
|
|
255
|
+
*/
|
|
256
|
+
createCachedResource: <T>(key: string, fetchFn: (signal: AbortSignal) => Promise<T>, options?: CachedResourceOptions<T>) => ResourceState<T>;
|
|
257
|
+
/**
|
|
258
|
+
* Clear all entries in this isolated cache.
|
|
259
|
+
*/
|
|
260
|
+
clearCache: (key?: string) => void;
|
|
261
|
+
/**
|
|
262
|
+
* Invalidate a specific key in this isolated cache.
|
|
263
|
+
*/
|
|
264
|
+
invalidateKey: (key: string, opts?: {
|
|
265
|
+
revalidate?: boolean;
|
|
266
|
+
force?: boolean;
|
|
267
|
+
}) => void;
|
|
268
|
+
/**
|
|
269
|
+
* Invalidate all entries with a specific tag.
|
|
270
|
+
*/
|
|
271
|
+
invalidateTag: (tag: string, opts?: {
|
|
272
|
+
revalidate?: boolean;
|
|
273
|
+
force?: boolean;
|
|
274
|
+
}) => void;
|
|
275
|
+
/**
|
|
276
|
+
* Invalidate all entries with any of the specified tags.
|
|
277
|
+
*/
|
|
278
|
+
invalidateTags: (tags: readonly string[], opts?: {
|
|
279
|
+
revalidate?: boolean;
|
|
280
|
+
force?: boolean;
|
|
281
|
+
}) => void;
|
|
282
|
+
/**
|
|
283
|
+
* Get the underlying cache Map (for debugging/inspection).
|
|
284
|
+
*/
|
|
285
|
+
getCache: () => Map<string, CacheEntry>;
|
|
286
|
+
/**
|
|
287
|
+
* Get cache keys by tag.
|
|
288
|
+
*/
|
|
289
|
+
getKeysByTag: (tag: string) => string[];
|
|
290
|
+
/**
|
|
291
|
+
* Configure cache limits for this instance.
|
|
292
|
+
*/
|
|
293
|
+
configure: (config: Partial<{
|
|
294
|
+
maxEntries: number;
|
|
295
|
+
warnOnEviction: boolean;
|
|
296
|
+
}>) => void;
|
|
297
|
+
}
|
|
298
|
+
export declare function createIsolatedCache(): IsolatedCache;
|
|
189
299
|
export {};
|