dalila 1.3.2 → 1.4.0
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 +87 -611
- package/dist/context/auto-scope.d.ts +65 -0
- package/dist/context/auto-scope.js +28 -0
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/raw.d.ts +1 -1
- package/dist/context/raw.js +1 -1
- package/dist/core/dev.d.ts +5 -0
- package/dist/core/dev.js +7 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/persist.d.ts +63 -0
- package/dist/core/persist.js +371 -0
- package/dist/core/scope.d.ts +17 -0
- package/dist/core/scope.js +29 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/bind.d.ts +59 -0
- package/dist/runtime/bind.js +336 -0
- package/dist/runtime/index.d.ts +10 -0
- package/dist/runtime/index.js +9 -0
- package/package.json +18 -1
- package/dist/compiler/dalila-lang.d.ts +0 -85
- package/dist/compiler/dalila-lang.js +0 -442
- package/dist/dom/elements.d.ts +0 -15
- package/dist/dom/elements.js +0 -100
- package/dist/dom/events.d.ts +0 -7
- package/dist/dom/events.js +0 -47
- package/dist/dom/index.d.ts +0 -2
- package/dist/dom/index.js +0 -2
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - Still keep things lifecycle-safe: the global scope can be disposed on page unload.
|
|
14
14
|
*/
|
|
15
15
|
import { type Scope } from '../core/scope.js';
|
|
16
|
+
import { type Signal } from '../core/signal.js';
|
|
16
17
|
import { createContext, type ContextToken, type TryInjectResult } from './context.js';
|
|
17
18
|
export type AutoScopePolicy = "warn" | "throw" | "silent";
|
|
18
19
|
export declare function setAutoScopePolicy(policy: AutoScopePolicy): void;
|
|
@@ -99,4 +100,68 @@ export declare function hasGlobalScope(): boolean;
|
|
|
99
100
|
* Intended for tests to ensure isolation between runs.
|
|
100
101
|
*/
|
|
101
102
|
export declare function resetGlobalScope(): void;
|
|
103
|
+
/**
|
|
104
|
+
* Creates a reactive context that wraps a signal.
|
|
105
|
+
*
|
|
106
|
+
* This is the recommended way to share reactive state across scopes.
|
|
107
|
+
* It combines the hierarchical lookup of Context with the reactivity of Signals.
|
|
108
|
+
*
|
|
109
|
+
* Example:
|
|
110
|
+
* ```ts
|
|
111
|
+
* const Theme = createSignalContext("theme", "dark");
|
|
112
|
+
*
|
|
113
|
+
* scope(() => {
|
|
114
|
+
* // Parent scope: create and provide the signal
|
|
115
|
+
* const theme = Theme.provide();
|
|
116
|
+
*
|
|
117
|
+
* effect(() => {
|
|
118
|
+
* console.log("Theme:", theme()); // Reactive!
|
|
119
|
+
* });
|
|
120
|
+
*
|
|
121
|
+
* theme.set("light"); // Updates propagate to all consumers
|
|
122
|
+
* });
|
|
123
|
+
*
|
|
124
|
+
* // Child scope somewhere in the tree
|
|
125
|
+
* scope(() => {
|
|
126
|
+
* const theme = Theme.inject(); // Get the signal from parent
|
|
127
|
+
* console.log(theme()); // "light"
|
|
128
|
+
* });
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export interface SignalContext<T> {
|
|
132
|
+
/**
|
|
133
|
+
* Create a signal with the initial value and provide it in the current scope.
|
|
134
|
+
* Returns the signal for immediate use.
|
|
135
|
+
*
|
|
136
|
+
* @param initialValue - Override the default initial value (optional)
|
|
137
|
+
*/
|
|
138
|
+
provide: (initialValue?: T) => Signal<T>;
|
|
139
|
+
/**
|
|
140
|
+
* Inject the signal from an ancestor scope.
|
|
141
|
+
* Throws if no provider exists in the scope hierarchy.
|
|
142
|
+
*/
|
|
143
|
+
inject: () => Signal<T>;
|
|
144
|
+
/**
|
|
145
|
+
* Try to inject the signal from an ancestor scope.
|
|
146
|
+
* Returns { found: true, signal } or { found: false, signal: undefined }.
|
|
147
|
+
*/
|
|
148
|
+
tryInject: () => {
|
|
149
|
+
found: true;
|
|
150
|
+
signal: Signal<T>;
|
|
151
|
+
} | {
|
|
152
|
+
found: false;
|
|
153
|
+
signal: undefined;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* The underlying context token (for advanced use cases).
|
|
157
|
+
*/
|
|
158
|
+
token: ContextToken<Signal<T>>;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Create a reactive context that wraps a signal.
|
|
162
|
+
*
|
|
163
|
+
* @param name - Debug name for the context
|
|
164
|
+
* @param defaultValue - Default initial value for the signal
|
|
165
|
+
*/
|
|
166
|
+
export declare function createSignalContext<T>(name: string, defaultValue: T): SignalContext<T>;
|
|
102
167
|
export { createContext };
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { getCurrentScope, createScope, isScopeDisposed, withScope } from '../core/scope.js';
|
|
16
16
|
import { isInDevMode } from '../core/dev.js';
|
|
17
|
+
import { signal as createSignal } from '../core/signal.js';
|
|
17
18
|
import { createContext, provide as rawProvide, tryInject as rawTryInject, debugListAvailableContexts, } from './context.js';
|
|
18
19
|
/**
|
|
19
20
|
* Symbol key for storing the global root scope.
|
|
@@ -349,5 +350,32 @@ export function resetGlobalScope() {
|
|
|
349
350
|
warnedGlobalProvide = false;
|
|
350
351
|
autoScopePolicy = "throw";
|
|
351
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* Create a reactive context that wraps a signal.
|
|
355
|
+
*
|
|
356
|
+
* @param name - Debug name for the context
|
|
357
|
+
* @param defaultValue - Default initial value for the signal
|
|
358
|
+
*/
|
|
359
|
+
export function createSignalContext(name, defaultValue) {
|
|
360
|
+
const token = createContext(name);
|
|
361
|
+
return {
|
|
362
|
+
provide(initialValue) {
|
|
363
|
+
const sig = createSignal(initialValue !== undefined ? initialValue : defaultValue);
|
|
364
|
+
provide(token, sig);
|
|
365
|
+
return sig;
|
|
366
|
+
},
|
|
367
|
+
inject() {
|
|
368
|
+
return inject(token);
|
|
369
|
+
},
|
|
370
|
+
tryInject() {
|
|
371
|
+
const result = tryInject(token);
|
|
372
|
+
if (result.found) {
|
|
373
|
+
return { found: true, signal: result.value };
|
|
374
|
+
}
|
|
375
|
+
return { found: false, signal: undefined };
|
|
376
|
+
},
|
|
377
|
+
token,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
352
380
|
// Re-export createContext for convenience.
|
|
353
381
|
export { createContext };
|
package/dist/context/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
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";
|
|
2
|
+
export { provide, inject, tryInject, scope, createProvider, createSignalContext, provideGlobal, injectGlobal, setAutoScopePolicy, hasGlobalScope, getGlobalScope, resetGlobalScope, type SignalContext, } from "./auto-scope.js";
|
package/dist/context/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
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";
|
|
2
|
+
export { provide, inject, tryInject, scope, createProvider, createSignalContext, provideGlobal, injectGlobal, setAutoScopePolicy, hasGlobalScope, getGlobalScope, resetGlobalScope, } from "./auto-scope.js";
|
package/dist/context/raw.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createContext, type ContextToken } from "./context.js";
|
|
1
|
+
export { createContext, setDeepHierarchyWarnDepth, type ContextToken, type TryInjectResult, } from "./context.js";
|
|
2
2
|
export { provide, inject, tryInject, injectMeta, debugListAvailableContexts, listAvailableContexts, } from "./context.js";
|
package/dist/context/raw.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createContext } from "./context.js";
|
|
1
|
+
export { createContext, setDeepHierarchyWarnDepth, } from "./context.js";
|
|
2
2
|
export { provide, inject, tryInject, injectMeta, debugListAvailableContexts, listAvailableContexts, } from "./context.js";
|
package/dist/core/dev.d.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
export declare function setDevMode(enabled: boolean): void;
|
|
2
2
|
export declare function isInDevMode(): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Initialize dev tools. Currently just enables dev mode.
|
|
5
|
+
* Returns a promise for future async initialization support.
|
|
6
|
+
*/
|
|
7
|
+
export declare function initDevTools(): Promise<void>;
|
package/dist/core/dev.js
CHANGED
|
@@ -5,3 +5,10 @@ export function setDevMode(enabled) {
|
|
|
5
5
|
export function isInDevMode() {
|
|
6
6
|
return isDevMode;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Initialize dev tools. Currently just enables dev mode.
|
|
10
|
+
* Returns a promise for future async initialization support.
|
|
11
|
+
*/
|
|
12
|
+
export async function initDevTools() {
|
|
13
|
+
setDevMode(true);
|
|
14
|
+
}
|
package/dist/core/index.d.ts
CHANGED
|
@@ -10,5 +10,5 @@ export * from "./key.js";
|
|
|
10
10
|
export * from "./resource.js";
|
|
11
11
|
export * from "./query.js";
|
|
12
12
|
export * from "./mutation.js";
|
|
13
|
-
export * from "./store.js";
|
|
14
13
|
export { batch, measure, mutate, configureScheduler, getSchedulerConfig } from "./scheduler.js";
|
|
14
|
+
export { persist, createJSONStorage, clearPersisted, createPreloadScript, createThemeScript, type StateStorage, type PersistOptions, type Serializer, type PreloadScriptOptions } from "./persist.js";
|
package/dist/core/index.js
CHANGED
|
@@ -10,5 +10,5 @@ export * from "./key.js";
|
|
|
10
10
|
export * from "./resource.js";
|
|
11
11
|
export * from "./query.js";
|
|
12
12
|
export * from "./mutation.js";
|
|
13
|
-
export * from "./store.js";
|
|
14
13
|
export { batch, measure, mutate, configureScheduler, getSchedulerConfig } from "./scheduler.js";
|
|
14
|
+
export { persist, createJSONStorage, clearPersisted, createPreloadScript, createThemeScript } from "./persist.js";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type Signal } from './signal.js';
|
|
2
|
+
/**
|
|
3
|
+
* Storage interface (compatible with localStorage, sessionStorage, etc.)
|
|
4
|
+
*/
|
|
5
|
+
export interface StateStorage {
|
|
6
|
+
getItem(key: string): string | null | Promise<string | null>;
|
|
7
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
8
|
+
removeItem?(key: string): void | Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Serialization interface
|
|
12
|
+
*/
|
|
13
|
+
export interface Serializer<T> {
|
|
14
|
+
serialize(value: T): string;
|
|
15
|
+
deserialize(value: string): T;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Persist options
|
|
19
|
+
*/
|
|
20
|
+
export interface PersistOptions<T> {
|
|
21
|
+
name: string;
|
|
22
|
+
storage?: StateStorage;
|
|
23
|
+
serializer?: Serializer<T>;
|
|
24
|
+
version?: number;
|
|
25
|
+
migrate?: (persistedState: unknown, version: number) => T;
|
|
26
|
+
merge?: 'replace' | 'shallow';
|
|
27
|
+
onRehydrate?: (state: T) => void;
|
|
28
|
+
onError?: (error: Error) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Dev-server only hint (not used by runtime yet).
|
|
31
|
+
* Kept for forward-compat with preload injection.
|
|
32
|
+
*/
|
|
33
|
+
preload?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create a persisted signal that automatically syncs with storage.
|
|
37
|
+
*/
|
|
38
|
+
export declare function persist<T>(baseSignal: Signal<T>, options: PersistOptions<T>): Signal<T>;
|
|
39
|
+
/**
|
|
40
|
+
* Helper to create JSON storage wrapper
|
|
41
|
+
*/
|
|
42
|
+
export declare function createJSONStorage(getStorage: () => StateStorage): StateStorage;
|
|
43
|
+
/**
|
|
44
|
+
* Clear persisted data for a given key
|
|
45
|
+
*/
|
|
46
|
+
export declare function clearPersisted(name: string, storage?: StateStorage): void;
|
|
47
|
+
/**
|
|
48
|
+
* Options for preload script generation
|
|
49
|
+
*/
|
|
50
|
+
export interface PreloadScriptOptions {
|
|
51
|
+
storageKey: string;
|
|
52
|
+
defaultValue: string;
|
|
53
|
+
target?: 'documentElement' | 'body';
|
|
54
|
+
attribute?: string;
|
|
55
|
+
storageType?: 'localStorage' | 'sessionStorage';
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Generate a minimal inline script to prevent FOUC.
|
|
59
|
+
*
|
|
60
|
+
* NOTE: Assumes the value in storage was JSON serialized (default serializer).
|
|
61
|
+
*/
|
|
62
|
+
export declare function createPreloadScript(options: PreloadScriptOptions): string;
|
|
63
|
+
export declare function createThemeScript(storageKey: string, defaultTheme?: string): string;
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { effect } from './signal.js';
|
|
2
|
+
import { getCurrentScope } from './scope.js';
|
|
3
|
+
const defaultSerializer = {
|
|
4
|
+
serialize: JSON.stringify,
|
|
5
|
+
deserialize: JSON.parse,
|
|
6
|
+
};
|
|
7
|
+
function isPromiseLike(v) {
|
|
8
|
+
return !!v && (typeof v === 'object' || typeof v === 'function') && typeof v.then === 'function';
|
|
9
|
+
}
|
|
10
|
+
function safeDefaultStorage() {
|
|
11
|
+
if (typeof window === 'undefined')
|
|
12
|
+
return undefined;
|
|
13
|
+
try {
|
|
14
|
+
return window.localStorage;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function toError(error) {
|
|
21
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
22
|
+
}
|
|
23
|
+
function parseVersion(v) {
|
|
24
|
+
if (typeof v !== 'string')
|
|
25
|
+
return 0;
|
|
26
|
+
const n = parseInt(v, 10);
|
|
27
|
+
return Number.isFinite(n) ? n : 0;
|
|
28
|
+
}
|
|
29
|
+
function isPlainObject(v) {
|
|
30
|
+
if (!v || typeof v !== 'object')
|
|
31
|
+
return false;
|
|
32
|
+
if (Array.isArray(v))
|
|
33
|
+
return false;
|
|
34
|
+
return Object.getPrototypeOf(v) === Object.prototype;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create a persisted signal that automatically syncs with storage.
|
|
38
|
+
*/
|
|
39
|
+
export function persist(baseSignal, options) {
|
|
40
|
+
const { name, storage = safeDefaultStorage(), serializer = defaultSerializer, version, merge = 'replace', onRehydrate, onError, migrate, } = options;
|
|
41
|
+
if (!name) {
|
|
42
|
+
throw new Error('[Dalila] persist() requires a "name" option');
|
|
43
|
+
}
|
|
44
|
+
if (!storage) {
|
|
45
|
+
console.warn(`[Dalila] persist(): storage not available for "${name}", persistence disabled`);
|
|
46
|
+
return baseSignal;
|
|
47
|
+
}
|
|
48
|
+
const storageKey = name;
|
|
49
|
+
const versionKey = version !== undefined ? `${name}:version` : undefined;
|
|
50
|
+
// ---- Core safety flags ----
|
|
51
|
+
// hydrated=false blocks writes so we don't overwrite storage before async read resolves.
|
|
52
|
+
let hydrated = false;
|
|
53
|
+
// becomes true if user changes signal before hydration completes
|
|
54
|
+
let dirtyBeforeHydrate = false;
|
|
55
|
+
/**
|
|
56
|
+
* Hydration write guard:
|
|
57
|
+
* The dirty listener below uses baseSignal.on(), which would also fire for the
|
|
58
|
+
* hydration set() itself. This guard prevents hydration from counting as "user dirty".
|
|
59
|
+
*/
|
|
60
|
+
let isHydrationWrite = false;
|
|
61
|
+
// temporary listener to detect changes before hydration completes
|
|
62
|
+
let removeDirtyListener = null;
|
|
63
|
+
/**
|
|
64
|
+
* Write-back dedupe (best effort):
|
|
65
|
+
* Avoid rewriting the same serialized value back to storage after hydration.
|
|
66
|
+
*
|
|
67
|
+
* - lastSaved: what we believe is currently in storage
|
|
68
|
+
* - pendingSaved: what we've already queued to write
|
|
69
|
+
*
|
|
70
|
+
* This prevents: storage -> hydrate -> effect runs -> serialize -> setItem(same value)
|
|
71
|
+
*/
|
|
72
|
+
let lastSaved = null;
|
|
73
|
+
let pendingSaved = null;
|
|
74
|
+
// Same idea for version key (when enabled)
|
|
75
|
+
let lastSavedVersion = null;
|
|
76
|
+
let pendingSavedVersion = null;
|
|
77
|
+
const handleError = (err, ctx) => {
|
|
78
|
+
const e = toError(err);
|
|
79
|
+
if (onError)
|
|
80
|
+
onError(e);
|
|
81
|
+
else
|
|
82
|
+
console.error(`[Dalila] persist(): ${ctx} "${name}"`, e);
|
|
83
|
+
};
|
|
84
|
+
const applyHydration = (value) => {
|
|
85
|
+
// Ensure dirty listener does not treat hydration as user mutation.
|
|
86
|
+
const setHydratedValue = (next) => {
|
|
87
|
+
isHydrationWrite = true;
|
|
88
|
+
try {
|
|
89
|
+
baseSignal.set(next);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
isHydrationWrite = false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
if (merge === 'shallow' && isPlainObject(value)) {
|
|
96
|
+
const current = baseSignal.peek();
|
|
97
|
+
if (isPlainObject(current)) {
|
|
98
|
+
setHydratedValue({ ...current, ...value });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
setHydratedValue(value);
|
|
103
|
+
};
|
|
104
|
+
// Ensure async writes don't land out-of-order (queue them)
|
|
105
|
+
let writeChain = Promise.resolve();
|
|
106
|
+
const queueWrite = (fn) => {
|
|
107
|
+
writeChain = writeChain
|
|
108
|
+
.then(() => fn())
|
|
109
|
+
.catch((err) => {
|
|
110
|
+
// If a queued write fails, clear pending so future writes aren't blocked.
|
|
111
|
+
pendingSaved = null;
|
|
112
|
+
pendingSavedVersion = null;
|
|
113
|
+
handleError(err, 'failed to save');
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Enqueue write with dedupe:
|
|
118
|
+
* - if value/version already matches what's saved (or already queued), no-op.
|
|
119
|
+
*/
|
|
120
|
+
const enqueuePersist = (serialized, versionStr) => {
|
|
121
|
+
const needsValue = serialized !== lastSaved && serialized !== pendingSaved;
|
|
122
|
+
const needsVersion = !!versionKey &&
|
|
123
|
+
versionStr !== null &&
|
|
124
|
+
versionStr !== lastSavedVersion &&
|
|
125
|
+
versionStr !== pendingSavedVersion;
|
|
126
|
+
if (!needsValue && !needsVersion)
|
|
127
|
+
return;
|
|
128
|
+
if (needsValue)
|
|
129
|
+
pendingSaved = serialized;
|
|
130
|
+
if (needsVersion)
|
|
131
|
+
pendingSavedVersion = versionStr;
|
|
132
|
+
queueWrite(async () => {
|
|
133
|
+
// Write value
|
|
134
|
+
if (needsValue) {
|
|
135
|
+
const r1 = storage.setItem(storageKey, serialized);
|
|
136
|
+
if (isPromiseLike(r1))
|
|
137
|
+
await r1;
|
|
138
|
+
lastSaved = serialized;
|
|
139
|
+
if (pendingSaved === serialized)
|
|
140
|
+
pendingSaved = null;
|
|
141
|
+
}
|
|
142
|
+
// Write version
|
|
143
|
+
if (needsVersion && versionKey && versionStr !== null) {
|
|
144
|
+
const r2 = storage.setItem(versionKey, versionStr);
|
|
145
|
+
if (isPromiseLike(r2))
|
|
146
|
+
await r2;
|
|
147
|
+
lastSavedVersion = versionStr;
|
|
148
|
+
if (pendingSavedVersion === versionStr)
|
|
149
|
+
pendingSavedVersion = null;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
const persistValue = (value) => {
|
|
154
|
+
// Don't write until hydration finishes (prevents overwriting stored state).
|
|
155
|
+
if (!hydrated)
|
|
156
|
+
return;
|
|
157
|
+
let serialized;
|
|
158
|
+
try {
|
|
159
|
+
serialized = serializer.serialize(value);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
handleError(err, 'failed to serialize');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const versionStr = versionKey ? String(version) : null;
|
|
166
|
+
enqueuePersist(serialized, versionStr);
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Hydrate from already-read storage strings.
|
|
170
|
+
* For sync storage we can read versionKey synchronously too.
|
|
171
|
+
*/
|
|
172
|
+
const hydrateFromStored = (storedValue, storedVersionRaw) => {
|
|
173
|
+
// Track current storage contents to avoid immediate write-back.
|
|
174
|
+
lastSaved = storedValue;
|
|
175
|
+
pendingSaved = null;
|
|
176
|
+
if (versionKey) {
|
|
177
|
+
lastSavedVersion = storedVersionRaw;
|
|
178
|
+
pendingSavedVersion = null;
|
|
179
|
+
}
|
|
180
|
+
const deserialized = serializer.deserialize(storedValue);
|
|
181
|
+
if (version !== undefined && versionKey && migrate) {
|
|
182
|
+
const storedVersion = parseVersion(storedVersionRaw);
|
|
183
|
+
if (storedVersion !== version) {
|
|
184
|
+
const migrated = migrate(deserialized, storedVersion);
|
|
185
|
+
applyHydration(migrated);
|
|
186
|
+
// Save migrated data to storage (don't leave old data with new version)
|
|
187
|
+
let migratedSerialized;
|
|
188
|
+
try {
|
|
189
|
+
migratedSerialized = serializer.serialize(baseSignal.peek());
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
handleError(err, 'failed to serialize migrated data');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
enqueuePersist(migratedSerialized, String(version));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
applyHydration(deserialized);
|
|
200
|
+
};
|
|
201
|
+
const finalizeHydration = (didUserChangeBefore) => {
|
|
202
|
+
hydrated = true;
|
|
203
|
+
// Remove temporary dirty listener (no longer needed after hydration)
|
|
204
|
+
if (removeDirtyListener) {
|
|
205
|
+
removeDirtyListener();
|
|
206
|
+
removeDirtyListener = null;
|
|
207
|
+
}
|
|
208
|
+
// If user changed before hydrate finished, we must persist current value at least once,
|
|
209
|
+
// because the change already happened while hydrated=false and won't re-trigger.
|
|
210
|
+
if (didUserChangeBefore) {
|
|
211
|
+
persistValue(baseSignal.peek());
|
|
212
|
+
}
|
|
213
|
+
if (onRehydrate) {
|
|
214
|
+
try {
|
|
215
|
+
onRehydrate(baseSignal.peek());
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
handleError(err, 'onRehydrate threw');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const hydrate = () => {
|
|
223
|
+
try {
|
|
224
|
+
const stored = storage.getItem(storageKey);
|
|
225
|
+
// async storage
|
|
226
|
+
if (isPromiseLike(stored)) {
|
|
227
|
+
return stored
|
|
228
|
+
.then(async (storedValue) => {
|
|
229
|
+
if (storedValue === null) {
|
|
230
|
+
finalizeHydration(dirtyBeforeHydrate);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// Track value even if we skip applying due to dirty (helps dedupe correctness).
|
|
234
|
+
lastSaved = storedValue;
|
|
235
|
+
pendingSaved = null;
|
|
236
|
+
// If user changed before hydration finished, prefer local state:
|
|
237
|
+
// do NOT apply storedValue (prevents "rollback" to old storage).
|
|
238
|
+
if (dirtyBeforeHydrate) {
|
|
239
|
+
// Best-effort track version too (if enabled), not required for correctness.
|
|
240
|
+
if (versionKey) {
|
|
241
|
+
try {
|
|
242
|
+
const v = await storage.getItem(versionKey);
|
|
243
|
+
lastSavedVersion = typeof v === 'string' ? v : null;
|
|
244
|
+
pendingSavedVersion = null;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// ignore
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
finalizeHydration(true);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
let storedVersionRaw = null;
|
|
254
|
+
if (versionKey) {
|
|
255
|
+
const v = await storage.getItem(versionKey);
|
|
256
|
+
storedVersionRaw = typeof v === 'string' ? v : null;
|
|
257
|
+
}
|
|
258
|
+
hydrateFromStored(storedValue, storedVersionRaw);
|
|
259
|
+
finalizeHydration(false);
|
|
260
|
+
})
|
|
261
|
+
.catch((err) => {
|
|
262
|
+
handleError(err, 'failed to hydrate');
|
|
263
|
+
// Even on error, allow future writes (otherwise persist becomes inert)
|
|
264
|
+
finalizeHydration(dirtyBeforeHydrate);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// sync storage
|
|
268
|
+
if (stored === null) {
|
|
269
|
+
finalizeHydration(false);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
let storedVersionRaw = null;
|
|
273
|
+
if (versionKey) {
|
|
274
|
+
const v = storage.getItem(versionKey);
|
|
275
|
+
storedVersionRaw = isPromiseLike(v) ? null : typeof v === 'string' ? v : null;
|
|
276
|
+
}
|
|
277
|
+
hydrateFromStored(stored, storedVersionRaw);
|
|
278
|
+
finalizeHydration(false);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
handleError(err, 'failed to hydrate');
|
|
282
|
+
finalizeHydration(dirtyBeforeHydrate);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
// ---- Set up persistence + dirty tracking ----
|
|
286
|
+
// Perfect dirty detection: track any changes before hydration completes.
|
|
287
|
+
// This catches even synchronous sets before the effect's first run.
|
|
288
|
+
if (!hydrated) {
|
|
289
|
+
removeDirtyListener = baseSignal.on(() => {
|
|
290
|
+
// Ignore hydration writes (hydration must NOT count as "user dirty").
|
|
291
|
+
if (!hydrated && !isHydrationWrite)
|
|
292
|
+
dirtyBeforeHydrate = true;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const scope = getCurrentScope();
|
|
296
|
+
if (scope) {
|
|
297
|
+
effect(() => {
|
|
298
|
+
const value = baseSignal();
|
|
299
|
+
// After hydration completes, persist normally
|
|
300
|
+
if (hydrated) {
|
|
301
|
+
persistValue(value);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
// Clean up temporary dirty listener when scope disposes (if still active)
|
|
305
|
+
if (removeDirtyListener) {
|
|
306
|
+
scope.onCleanup(removeDirtyListener);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
// No scope: use manual subscription.
|
|
311
|
+
// Dirty-before-hydrate is still handled by the temporary dirty listener above.
|
|
312
|
+
baseSignal.on((value) => {
|
|
313
|
+
if (hydrated) {
|
|
314
|
+
persistValue(value);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
// Hydrate after wiring subscribers so async hydration sets can trigger persist if needed.
|
|
319
|
+
const hydration = hydrate();
|
|
320
|
+
if (isPromiseLike(hydration)) {
|
|
321
|
+
hydration.catch((err) => {
|
|
322
|
+
// already handled inside hydrate(), but keep as last-resort safety
|
|
323
|
+
console.error(`[Dalila] persist(): hydration promise rejected for "${name}"`, err);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return baseSignal;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Helper to create JSON storage wrapper
|
|
330
|
+
*/
|
|
331
|
+
export function createJSONStorage(getStorage) {
|
|
332
|
+
return getStorage();
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Clear persisted data for a given key
|
|
336
|
+
*/
|
|
337
|
+
export function clearPersisted(name, storage = safeDefaultStorage() ?? {}) {
|
|
338
|
+
if (!storage.removeItem) {
|
|
339
|
+
console.warn(`[Dalila] clearPersisted(): storage.removeItem not available for "${name}"`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
void storage.removeItem(name);
|
|
344
|
+
void storage.removeItem(`${name}:version`);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
console.error(`[Dalila] clearPersisted(): failed for "${name}"`, err);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Generate a minimal inline script to prevent FOUC.
|
|
352
|
+
*
|
|
353
|
+
* NOTE: Assumes the value in storage was JSON serialized (default serializer).
|
|
354
|
+
*/
|
|
355
|
+
export function createPreloadScript(options) {
|
|
356
|
+
const { storageKey, defaultValue, target = 'documentElement', attribute = 'data-theme', storageType = 'localStorage', } = options;
|
|
357
|
+
// Use JSON.stringify to safely embed strings (avoid breaking quotes / injection)
|
|
358
|
+
const k = JSON.stringify(storageKey);
|
|
359
|
+
const d = JSON.stringify(defaultValue);
|
|
360
|
+
const a = JSON.stringify(attribute);
|
|
361
|
+
// Still minified
|
|
362
|
+
return `(function(){try{var s=${storageType}.getItem(${k});var v=s==null?${d}:JSON.parse(s);document.${target}.setAttribute(${a},v)}catch(e){document.${target}.setAttribute(${a},${d})}})();`;
|
|
363
|
+
}
|
|
364
|
+
export function createThemeScript(storageKey, defaultTheme = 'light') {
|
|
365
|
+
return createPreloadScript({
|
|
366
|
+
storageKey,
|
|
367
|
+
defaultValue: defaultTheme,
|
|
368
|
+
target: 'documentElement',
|
|
369
|
+
attribute: 'data-theme',
|
|
370
|
+
});
|
|
371
|
+
}
|
package/dist/core/scope.d.ts
CHANGED
|
@@ -55,3 +55,20 @@ export declare function setCurrentScope(scope: Scope | null): void;
|
|
|
55
55
|
* - `effect()` can auto-dispose when the scope ends
|
|
56
56
|
*/
|
|
57
57
|
export declare function withScope<T>(scope: Scope, fn: () => T): T;
|
|
58
|
+
/**
|
|
59
|
+
* Async version of withScope that properly maintains scope during await.
|
|
60
|
+
*
|
|
61
|
+
* IMPORTANT: Use this instead of withScope when fn is async, because
|
|
62
|
+
* withScope() restores the previous scope immediately when the Promise
|
|
63
|
+
* is returned, not when it resolves. This means anything created after
|
|
64
|
+
* an await would not be in the scope.
|
|
65
|
+
*
|
|
66
|
+
* Example:
|
|
67
|
+
* ```ts
|
|
68
|
+
* await withScopeAsync(scope, async () => {
|
|
69
|
+
* await fetch(...);
|
|
70
|
+
* const sig = signal(0); // ← This will be in scope
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function withScopeAsync<T>(scope: Scope, fn: () => Promise<T>): Promise<T>;
|
package/dist/core/scope.js
CHANGED
|
@@ -140,3 +140,32 @@ export function withScope(scope, fn) {
|
|
|
140
140
|
currentScope = prevScope;
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Async version of withScope that properly maintains scope during await.
|
|
145
|
+
*
|
|
146
|
+
* IMPORTANT: Use this instead of withScope when fn is async, because
|
|
147
|
+
* withScope() restores the previous scope immediately when the Promise
|
|
148
|
+
* is returned, not when it resolves. This means anything created after
|
|
149
|
+
* an await would not be in the scope.
|
|
150
|
+
*
|
|
151
|
+
* Example:
|
|
152
|
+
* ```ts
|
|
153
|
+
* await withScopeAsync(scope, async () => {
|
|
154
|
+
* await fetch(...);
|
|
155
|
+
* const sig = signal(0); // ← This will be in scope
|
|
156
|
+
* });
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export async function withScopeAsync(scope, fn) {
|
|
160
|
+
if (isScopeDisposed(scope)) {
|
|
161
|
+
throw new Error('[Dalila] withScopeAsync() cannot enter a disposed scope.');
|
|
162
|
+
}
|
|
163
|
+
const prevScope = currentScope;
|
|
164
|
+
currentScope = scope;
|
|
165
|
+
try {
|
|
166
|
+
return await fn();
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
currentScope = prevScope;
|
|
170
|
+
}
|
|
171
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED