@bitstillery/mithril 3.0.0-AA
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/LICENSE +21 -0
- package/README.md +185 -0
- package/api/mount-redraw.ts +173 -0
- package/api/router-server.ts +88 -0
- package/api/router.ts +670 -0
- package/browser.ts +10 -0
- package/htm.ts +7 -0
- package/hyperscript.ts +8 -0
- package/index.ts +134 -0
- package/jsx.d.ts +16 -0
- package/mount.ts +10 -0
- package/package.json +66 -0
- package/pathname/build.ts +40 -0
- package/pathname/compileTemplate.ts +49 -0
- package/pathname/parse.ts +19 -0
- package/querystring/build.ts +22 -0
- package/querystring/parse.ts +50 -0
- package/redraw.ts +10 -0
- package/render/cachedAttrsIsStaticMap.ts +10 -0
- package/render/delayedRemoval.ts +1 -0
- package/render/domFor.ts +23 -0
- package/render/emptyAttrs.ts +2 -0
- package/render/fragment.ts +11 -0
- package/render/hyperscript.ts +125 -0
- package/render/hyperscriptVnode.ts +21 -0
- package/render/render.ts +1307 -0
- package/render/renderToString.ts +494 -0
- package/render/ssrState.ts +366 -0
- package/render/trust.ts +6 -0
- package/render/vnode.ts +131 -0
- package/route.ts +11 -0
- package/server/logger.ts +223 -0
- package/server/session.ts +87 -0
- package/server/ssr.ts +205 -0
- package/server/ssrLogger.ts +13 -0
- package/server.ts +101 -0
- package/signal.ts +320 -0
- package/ssrContext.ts +87 -0
- package/state.ts +1026 -0
- package/store.ts +457 -0
- package/util/censor.ts +46 -0
- package/util/decodeURIComponentSafe.ts +34 -0
- package/util/hasOwn.ts +2 -0
- package/util/next_tick.ts +45 -0
- package/util/ssr.ts +385 -0
- package/util/uri.ts +126 -0
- package/version.ts +3 -0
package/store.ts
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import {state, State, updateStateRegistry} from './state'
|
|
2
|
+
import {serializeStore, deserializeStore} from './render/ssrState'
|
|
3
|
+
import {ComputedSignal} from './signal'
|
|
4
|
+
|
|
5
|
+
// Helper function to restore computed properties (same as in ssrState.ts)
|
|
6
|
+
function restoreComputedProperties(state: State<any>, initial: any): void {
|
|
7
|
+
if (!initial || typeof initial !== 'object') {
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function is_object(v: any): boolean {
|
|
12
|
+
return v && typeof v === 'object' && !Array.isArray(v)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function restore(obj: any, target: any, prefix: string = ''): void {
|
|
16
|
+
for (const key in obj) {
|
|
17
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
18
|
+
const value = obj[key]
|
|
19
|
+
|
|
20
|
+
if (typeof value === 'function') {
|
|
21
|
+
// Set function property - state proxy will convert to ComputedSignal
|
|
22
|
+
const keys = prefix ? prefix.split('.').filter((k) => k) : []
|
|
23
|
+
let targetState = target
|
|
24
|
+
for (let i = 0; i < keys.length; i++) {
|
|
25
|
+
if (!targetState || !targetState[keys[i]]) {
|
|
26
|
+
// Nested state doesn't exist yet, skip
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
targetState = targetState[keys[i]]
|
|
30
|
+
}
|
|
31
|
+
if (targetState) {
|
|
32
|
+
// Clear any existing signal in signalMap so function is re-initialized as ComputedSignal
|
|
33
|
+
if (typeof targetState === 'object' && (targetState as any).__isState) {
|
|
34
|
+
const signalMap = (targetState as any).__signalMap
|
|
35
|
+
if (signalMap && signalMap instanceof Map) {
|
|
36
|
+
signalMap.delete(key)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
targetState[key] = value
|
|
40
|
+
}
|
|
41
|
+
} else if (is_object(value)) {
|
|
42
|
+
// Recursively restore nested computed properties
|
|
43
|
+
const nestedPrefix = prefix ? `${prefix}.${key}` : key
|
|
44
|
+
restore(value, target, nestedPrefix)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
restore(initial, state)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Utility functions for Store class
|
|
54
|
+
function isState(value: any): boolean {
|
|
55
|
+
return value && typeof value === 'object' && (value as any).__isState === true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function is_object(v: any): boolean {
|
|
59
|
+
return v && typeof v === 'object' && !Array.isArray(v)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function copy_object<T>(obj: T): T {
|
|
63
|
+
return JSON.parse(JSON.stringify(obj))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Deep copy object while preserving functions (computed properties)
|
|
68
|
+
* Used for merging templates that may contain computed properties
|
|
69
|
+
*/
|
|
70
|
+
function copy_object_preserve_functions<T>(obj: T): T {
|
|
71
|
+
if (obj === null || typeof obj !== 'object') {
|
|
72
|
+
return obj
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(obj)) {
|
|
76
|
+
return obj.map((item) => copy_object_preserve_functions(item)) as T
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof obj === 'function') {
|
|
80
|
+
return obj as T
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result: any = {}
|
|
84
|
+
for (const key in obj) {
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
86
|
+
const value = (obj as any)[key]
|
|
87
|
+
if (typeof value === 'function') {
|
|
88
|
+
// Preserve functions (computed properties)
|
|
89
|
+
result[key] = value
|
|
90
|
+
} else {
|
|
91
|
+
// Deep copy other values
|
|
92
|
+
result[key] = copy_object_preserve_functions(value)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return result as T
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function merge_deep(target: any, ...sources: any[]): any {
|
|
100
|
+
if (!sources.length) return target
|
|
101
|
+
const source = sources.shift()
|
|
102
|
+
|
|
103
|
+
if (is_object(target) && is_object(source)) {
|
|
104
|
+
for (const key in source) {
|
|
105
|
+
if (Array.isArray(source[key]) && Array.isArray(target[key])) {
|
|
106
|
+
// Splice the contents of source[key] into target[key]
|
|
107
|
+
target[key].splice(0, target[key].length, ...source[key])
|
|
108
|
+
} else if (is_object(source[key])) {
|
|
109
|
+
if (!target[key]) Object.assign(target, {[key]: {}})
|
|
110
|
+
merge_deep(target[key], source[key])
|
|
111
|
+
} else {
|
|
112
|
+
Object.assign(target, {[key]: source[key]})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return merge_deep(target, ...sources)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const DEFAULT_LOOKUP_VERIFY_INTERVAL = 1000 * 10 // 10 seconds
|
|
121
|
+
const DEFAULT_LOOKUP_TTL = 1000 * 60 * 60 * 24 // 1 day
|
|
122
|
+
|
|
123
|
+
// Counter for generating unique store instance names
|
|
124
|
+
let storeInstanceCounter = 0
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Store class - wraps state() with persistence functionality
|
|
128
|
+
* Provides load/save/blueprint methods for localStorage/sessionStorage persistence
|
|
129
|
+
*
|
|
130
|
+
* State types:
|
|
131
|
+
* - saved: localStorage (survives browser restarts)
|
|
132
|
+
* - temporary: not persisted (resets on reload)
|
|
133
|
+
* - tab: sessionStorage (survives page reloads, clears when tab closes)
|
|
134
|
+
* - session: server-side session storage (optional, off by default; requires backend, hydrated via SSR)
|
|
135
|
+
*/
|
|
136
|
+
export class Store<T extends Record<string, any> = Record<string, any>> {
|
|
137
|
+
private stateInstance: State<T>
|
|
138
|
+
private templates = {
|
|
139
|
+
saved: {} as Partial<T>,
|
|
140
|
+
temporary: {} as Partial<T>,
|
|
141
|
+
tab: {} as Partial<T>,
|
|
142
|
+
session: {} as Partial<T>,
|
|
143
|
+
}
|
|
144
|
+
private lookup_verify_interval: number | null = null
|
|
145
|
+
private lookup_ttl: number
|
|
146
|
+
private computedPropertiesSetup?: () => void
|
|
147
|
+
|
|
148
|
+
constructor(options: {lookup_ttl?: number} = {lookup_ttl: DEFAULT_LOOKUP_TTL}) {
|
|
149
|
+
this.lookup_ttl = options.lookup_ttl || DEFAULT_LOOKUP_TTL
|
|
150
|
+
// Initialize with empty state, will be loaded later (ADR-0013: defer computeds until ready() is called)
|
|
151
|
+
const instanceName = `store.instance.${storeInstanceCounter++}`
|
|
152
|
+
this.stateInstance = state({} as T, instanceName, {deferComputed: true})
|
|
153
|
+
|
|
154
|
+
if (typeof window !== 'undefined' && !this.lookup_verify_interval) {
|
|
155
|
+
// Check every 10 seconds for outdated lookup paths. This is
|
|
156
|
+
// to keep the lookup store clean.
|
|
157
|
+
this.lookup_verify_interval = window.setInterval(() => {
|
|
158
|
+
this.clean_lookup()
|
|
159
|
+
}, DEFAULT_LOOKUP_VERIFY_INTERVAL)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get state(): State<T> {
|
|
164
|
+
return this.stateInstance
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Allow evaluation of computed properties (ADR-0013). Call after load() and app setup
|
|
169
|
+
* (e.g. after $s, context, or route are ready) so computeds that depend on them can run.
|
|
170
|
+
*/
|
|
171
|
+
ready(): void {
|
|
172
|
+
;(this.stateInstance as any).allowComputed?.()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Merge deep on object `state`, but only the key/values in `blueprint`.
|
|
177
|
+
*/
|
|
178
|
+
blueprint(state: T, blueprint: Partial<T>): Partial<T> {
|
|
179
|
+
if (state == null || typeof state !== 'object') {
|
|
180
|
+
return {} as Partial<T>
|
|
181
|
+
}
|
|
182
|
+
const result: any = {}
|
|
183
|
+
for (const key of Object.keys(blueprint)) {
|
|
184
|
+
if (Object.prototype.hasOwnProperty.call(state, key)) {
|
|
185
|
+
const blueprintValue = (blueprint as any)[key]
|
|
186
|
+
const stateValue = (state as any)[key]
|
|
187
|
+
if (!Array.isArray(blueprintValue) && blueprintValue !== null && is_object(blueprintValue)) {
|
|
188
|
+
// (!) Convention: The contents of a state key with the name 'lookup' is
|
|
189
|
+
// always one-one copied from the state, instead of being
|
|
190
|
+
// blueprinted per-key. This is to accomodate key/value
|
|
191
|
+
// lookups, without having to define each key in the
|
|
192
|
+
// state's persistent section.
|
|
193
|
+
if (key === 'lookup') {
|
|
194
|
+
result[key] = copy_object(stateValue)
|
|
195
|
+
} else {
|
|
196
|
+
result[key] = this.blueprint(stateValue, blueprintValue)
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
result[key] = stateValue
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return result as Partial<T>
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
clean_lookup() {
|
|
207
|
+
// Skip during SSR (server-side rendering in Bun)
|
|
208
|
+
// Check both window existence and __SSR_MODE__ flag for safety
|
|
209
|
+
if (typeof window === 'undefined' || globalThis.__SSR_MODE__) {
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let store_modified = false
|
|
214
|
+
const lookup = (this.stateInstance as any).lookup
|
|
215
|
+
if (!lookup) return
|
|
216
|
+
|
|
217
|
+
// Build a new lookup object with only valid entries
|
|
218
|
+
const newLookup: Record<string, any> = {}
|
|
219
|
+
// Get keys first to avoid iteration issues when deleting
|
|
220
|
+
// Filter out $ prefix keys added by reactive proxy
|
|
221
|
+
const keys = Object.keys(lookup).filter((k) => !k.startsWith('$') && k !== '__isState' && k !== '__signalMap')
|
|
222
|
+
for (const key of keys) {
|
|
223
|
+
const value = lookup[key]
|
|
224
|
+
// Previously stored values may not have a modified timestamp.
|
|
225
|
+
// Set it now, and let it be cleaned up after the interval.
|
|
226
|
+
if (!value || !is_object(value)) {
|
|
227
|
+
// Skip invalid entries
|
|
228
|
+
store_modified = true
|
|
229
|
+
} else {
|
|
230
|
+
if (!(value as any).modified) {
|
|
231
|
+
;(value as any).modified = Date.now()
|
|
232
|
+
store_modified = true
|
|
233
|
+
}
|
|
234
|
+
if ((value as any).modified >= Date.now() - this.lookup_ttl) {
|
|
235
|
+
// Keep entries that are not expired
|
|
236
|
+
newLookup[key] = value
|
|
237
|
+
} else {
|
|
238
|
+
console.info(`[store] removing outdated lookup path: ${key}`)
|
|
239
|
+
store_modified = true
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (store_modified) {
|
|
244
|
+
// Replace lookup with cleaned version
|
|
245
|
+
;(this.stateInstance as any).lookup = newLookup
|
|
246
|
+
this.save()
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get key from local storage. If the item does not exist or
|
|
252
|
+
* cannot be retrieved, the default "{}" is returned.
|
|
253
|
+
*/
|
|
254
|
+
get(key: string): string {
|
|
255
|
+
if (typeof window === 'undefined') return '{}'
|
|
256
|
+
try {
|
|
257
|
+
return window.localStorage.getItem(key) || '{}'
|
|
258
|
+
} catch {
|
|
259
|
+
return '{}'
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
get_tab_storage(key: string): string {
|
|
264
|
+
if (typeof window === 'undefined') return '{}'
|
|
265
|
+
try {
|
|
266
|
+
return window.sessionStorage.getItem(key) || '{}'
|
|
267
|
+
} catch {
|
|
268
|
+
return '{}'
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
load(saved: Partial<T>, temporary: Partial<T>, tab: Partial<T> = {} as Partial<T>, session: Partial<T> = {} as Partial<T>) {
|
|
273
|
+
const restored_state = {
|
|
274
|
+
tab: this.get_tab_storage('store'),
|
|
275
|
+
store: this.get('store'),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.templates = {
|
|
279
|
+
saved,
|
|
280
|
+
temporary,
|
|
281
|
+
tab,
|
|
282
|
+
session,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
restored_state.store = JSON.parse(restored_state.store)
|
|
287
|
+
restored_state.tab = JSON.parse(restored_state.tab)
|
|
288
|
+
} catch (err) {
|
|
289
|
+
console.log(`[store] failed to parse store/tab: ${err}`)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const store_state = merge_deep(copy_object(this.templates.saved), copy_object(restored_state.store ?? {}))
|
|
293
|
+
// override with previous identity for a better version bump experience.
|
|
294
|
+
if (restored_state.store && typeof restored_state.store === 'object' && 'identity' in (restored_state.store as object)) {
|
|
295
|
+
store_state.identity = (restored_state.store as any).identity
|
|
296
|
+
}
|
|
297
|
+
let tab_state
|
|
298
|
+
|
|
299
|
+
if (!restored_state.tab) {
|
|
300
|
+
console.log('[store] loading tab state from local store')
|
|
301
|
+
tab_state = merge_deep(copy_object(this.templates.tab), store_state.tab)
|
|
302
|
+
} else {
|
|
303
|
+
console.log('[store] restoring existing tab state')
|
|
304
|
+
tab_state = merge_deep(copy_object(this.templates.tab), copy_object(restored_state.tab))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Always merge tab_state into store_state to ensure it's included in final_state
|
|
308
|
+
merge_deep(store_state, {tab: tab_state})
|
|
309
|
+
|
|
310
|
+
// Merge temporary into store_state
|
|
311
|
+
// Note: copy_object removes functions, but temporary data shouldn't have functions anyway
|
|
312
|
+
// (computed properties are handled separately via mergedInitial)
|
|
313
|
+
const temp_state = merge_deep(store_state, copy_object(temporary))
|
|
314
|
+
|
|
315
|
+
// Merge session into temp_state to create final_state
|
|
316
|
+
// Session state comes from server (SSR), not localStorage
|
|
317
|
+
const final_state = merge_deep(temp_state, copy_object(session))
|
|
318
|
+
|
|
319
|
+
// Merge templates (including computed properties) into "merged initial state"
|
|
320
|
+
// This will be stored in registry so computed properties can be automatically restored
|
|
321
|
+
// Use copy_object_preserve_functions to deep copy while preserving functions
|
|
322
|
+
// Note: tab template structure needs to match final_state structure (nested under 'tab')
|
|
323
|
+
const mergedInitialSaved = copy_object_preserve_functions(saved)
|
|
324
|
+
const mergedInitialTemporary = copy_object_preserve_functions(temporary)
|
|
325
|
+
// Tab template is merged into store_state.tab, so wrap it in {tab: ...}
|
|
326
|
+
const mergedInitialTab = tab && Object.keys(tab).length > 0 ? {tab: copy_object_preserve_functions(tab)} : {}
|
|
327
|
+
// Session template is merged directly (no nesting needed, structure matches final_state)
|
|
328
|
+
const mergedInitialSession = copy_object_preserve_functions(session)
|
|
329
|
+
const mergedInitial = merge_deep(mergedInitialSaved, mergedInitialTemporary, mergedInitialTab, mergedInitialSession)
|
|
330
|
+
|
|
331
|
+
// Update registry entry to store merged templates as "initial" state
|
|
332
|
+
// This allows deserializeAllStates() to automatically restore computed properties
|
|
333
|
+
updateStateRegistry(this.stateInstance, mergedInitial)
|
|
334
|
+
|
|
335
|
+
// Use deserializeStore() instead of custom updateState()
|
|
336
|
+
// This ensures consistency with SSR deserialization mechanism
|
|
337
|
+
deserializeStore(this.stateInstance, final_state)
|
|
338
|
+
|
|
339
|
+
// Restore computed properties from merged templates
|
|
340
|
+
// This ensures computed properties are available immediately after load()
|
|
341
|
+
// Note: mergedInitial contains all templates (saved, temporary, tab, session) with computed properties
|
|
342
|
+
restoreComputedProperties(this.stateInstance, mergedInitial)
|
|
343
|
+
|
|
344
|
+
// Note: setupComputedProperties() callback is no longer needed, but kept for backward compatibility
|
|
345
|
+
if (this.computedPropertiesSetup) {
|
|
346
|
+
this.computedPropertiesSetup()
|
|
347
|
+
}
|
|
348
|
+
// ADR-0013: open deferred-computed gate so computeds can run after load
|
|
349
|
+
this.ready()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Register a function to set up computed properties after each load()
|
|
354
|
+
* This ensures computed properties are always available, even after reloading from storage
|
|
355
|
+
*/
|
|
356
|
+
setupComputedProperties(setupFn: () => void): void {
|
|
357
|
+
this.computedPropertiesSetup = setupFn
|
|
358
|
+
// Call immediately to set up computed properties for current state
|
|
359
|
+
setupFn()
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Persist state to storage. When no options are passed, saves to localStorage (saved) and
|
|
364
|
+
* sessionStorage (tab). Session (server-side) is off by default; pass { session: true } to persist it.
|
|
365
|
+
*/
|
|
366
|
+
async save(options?: {saved?: boolean; tab?: boolean; session?: boolean}): Promise<void> {
|
|
367
|
+
// Skip saving during SSR (server-side rendering in Bun)
|
|
368
|
+
// On the server, there's no localStorage/sessionStorage and no need to persist state
|
|
369
|
+
if (globalThis.__SSR_MODE__) {
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Default to saving saved/tab when no options; session is opt-in (off by default)
|
|
374
|
+
const saveSaved = options?.saved ?? options === undefined
|
|
375
|
+
const saveTab = options?.tab ?? options === undefined
|
|
376
|
+
const saveSession = options?.session === true
|
|
377
|
+
|
|
378
|
+
// Use SSR serialization which properly handles State objects and skips ComputedSignal properties
|
|
379
|
+
// This is the same mechanism used for SSR, ensuring consistency
|
|
380
|
+
const statePlain = serializeStore(this.stateInstance)
|
|
381
|
+
|
|
382
|
+
// Save to localStorage (saved state)
|
|
383
|
+
if (saveSaved && this.templates.saved) {
|
|
384
|
+
this.set('store', this.blueprint(statePlain, copy_object(this.templates.saved)))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Save to sessionStorage (tab state)
|
|
388
|
+
if (saveTab && this.templates.tab) {
|
|
389
|
+
const tabState = (this.stateInstance as any).tab
|
|
390
|
+
if (tabState) {
|
|
391
|
+
// Get the tab template - unwrap if it's nested under a 'tab' key
|
|
392
|
+
// The template might be: { tab: { sessionId, ... } } or { sessionId, ... }
|
|
393
|
+
// The state is always: { sessionId, ... }
|
|
394
|
+
const tabTemplate = (this.templates.tab as any).tab || this.templates.tab
|
|
395
|
+
|
|
396
|
+
// Check if tab is a State object
|
|
397
|
+
if (isState(tabState)) {
|
|
398
|
+
const tabPlain = serializeStore(tabState)
|
|
399
|
+
// blueprint expects both arguments to have the same structure
|
|
400
|
+
this.set_tab('store', this.blueprint(tabPlain, copy_object(tabTemplate)))
|
|
401
|
+
} else {
|
|
402
|
+
// Plain object tab
|
|
403
|
+
this.set_tab('store', this.blueprint(tabState, copy_object(tabTemplate)))
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
// No tab state - save empty tab based on template structure
|
|
407
|
+
const tabTemplate = (this.templates.tab as any).tab || this.templates.tab
|
|
408
|
+
if (tabTemplate && Object.keys(tabTemplate).length > 0) {
|
|
409
|
+
this.set_tab('store', this.blueprint({} as any, copy_object(tabTemplate)))
|
|
410
|
+
} else {
|
|
411
|
+
this.set_tab('store', {})
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Save to session API (session state) - async by nature
|
|
417
|
+
// Only save session on client side (not during SSR)
|
|
418
|
+
if (
|
|
419
|
+
saveSession &&
|
|
420
|
+
this.templates.session &&
|
|
421
|
+
Object.keys(this.templates.session).length > 0 &&
|
|
422
|
+
typeof window !== 'undefined'
|
|
423
|
+
) {
|
|
424
|
+
const sessionData = this.blueprint(statePlain, copy_object(this.templates.session))
|
|
425
|
+
|
|
426
|
+
// Call API endpoint with batched session updates
|
|
427
|
+
const endpoint = '/api/session'
|
|
428
|
+
const response = await fetch(endpoint, {
|
|
429
|
+
method: 'POST',
|
|
430
|
+
headers: {'Content-Type': 'application/json'},
|
|
431
|
+
body: JSON.stringify(sessionData),
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
if (!response.ok) {
|
|
435
|
+
throw new Error(`Failed to save session state: ${response.statusText}`)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
set(key: string, item: object): void {
|
|
441
|
+
if (typeof window === 'undefined') return
|
|
442
|
+
try {
|
|
443
|
+
return window.localStorage.setItem(key, JSON.stringify(item))
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.error('Cannot use Local Storage; continue without.', err)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
set_tab(key: string, item: object): void {
|
|
450
|
+
if (typeof window === 'undefined') return
|
|
451
|
+
try {
|
|
452
|
+
return window.sessionStorage.setItem(key, JSON.stringify(item))
|
|
453
|
+
} catch (err) {
|
|
454
|
+
console.error('Cannot use Session Storage; continue without.', err)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
package/util/censor.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Note: this is mildly perf-sensitive.
|
|
2
|
+
//
|
|
3
|
+
// It does *not* use `delete` - dynamic `delete`s usually cause objects to bail
|
|
4
|
+
// out into dictionary mode and just generally cause a bunch of optimization
|
|
5
|
+
// issues within engines.
|
|
6
|
+
//
|
|
7
|
+
// Ideally, I would've preferred to do this, if it weren't for the optimization
|
|
8
|
+
// issues:
|
|
9
|
+
//
|
|
10
|
+
// ```ts
|
|
11
|
+
// const hasOwn = require("./hasOwn")
|
|
12
|
+
// const magic = [
|
|
13
|
+
// "key", "oninit", "oncreate", "onbeforeupdate", "onupdate",
|
|
14
|
+
// "onbeforeremove", "onremove",
|
|
15
|
+
// ]
|
|
16
|
+
// export default (attrs, extras) => {
|
|
17
|
+
// const result = Object.assign(Object.create(null), attrs)
|
|
18
|
+
// for (const key of magic) delete result[key]
|
|
19
|
+
// if (extras != null) for (const key of extras) delete result[key]
|
|
20
|
+
// return result
|
|
21
|
+
// }
|
|
22
|
+
// ```
|
|
23
|
+
|
|
24
|
+
import hasOwn from './hasOwn'
|
|
25
|
+
|
|
26
|
+
const magic = /^(?:key|oninit|oncreate|onbeforeupdate|onupdate|onbeforeremove|onremove)$/
|
|
27
|
+
|
|
28
|
+
export default function censor(attrs: Record<string, any>, extras?: string[]): Record<string, any> {
|
|
29
|
+
const result: Record<string, any> = {}
|
|
30
|
+
|
|
31
|
+
if (extras != null) {
|
|
32
|
+
for (const key in attrs) {
|
|
33
|
+
if (hasOwn.call(attrs, key) && !magic.test(key) && extras.indexOf(key) < 0) {
|
|
34
|
+
result[key] = attrs[key]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
for (const key in attrs) {
|
|
39
|
+
if (hasOwn.call(attrs, key) && !magic.test(key)) {
|
|
40
|
+
result[key] = attrs[key]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Percent encodings encode UTF-8 bytes, so this regexp needs to match that.
|
|
3
|
+
Here's how UTF-8 encodes stuff:
|
|
4
|
+
- `00-7F`: 1-byte, for U+0000-U+007F
|
|
5
|
+
- `C2-DF 80-BF`: 2-byte, for U+0080-U+07FF
|
|
6
|
+
- `E0-EF 80-BF 80-BF`: 3-byte, encodes U+0800-U+FFFF
|
|
7
|
+
- `F0-F4 80-BF 80-BF 80-BF`: 4-byte, encodes U+10000-U+10FFFF
|
|
8
|
+
In this, there's a number of invalid byte sequences:
|
|
9
|
+
- `80-BF`: Continuation byte, invalid as start
|
|
10
|
+
- `C0-C1 80-BF`: Overlong encoding for U+0000-U+007F
|
|
11
|
+
- `E0 80-9F 80-BF`: Overlong encoding for U+0080-U+07FF
|
|
12
|
+
- `ED A0-BF 80-BF`: Encoding for UTF-16 surrogate U+D800-U+DFFF
|
|
13
|
+
- `F0 80-8F 80-BF 80-BF`: Overlong encoding for U+0800-U+FFFF
|
|
14
|
+
- `F4 90-BF`: RFC 3629 restricted UTF-8 to only code points UTF-16 could encode.
|
|
15
|
+
- `F5-FF`: RFC 3629 restricted UTF-8 to only code points UTF-16 could encode.
|
|
16
|
+
So in reality, only the following sequences can encode are valid characters:
|
|
17
|
+
- 00-7F
|
|
18
|
+
- C2-DF 80-BF
|
|
19
|
+
- E0 A0-BF 80-BF
|
|
20
|
+
- E1-EC 80-BF 80-BF
|
|
21
|
+
- ED 80-9F 80-BF
|
|
22
|
+
- EE-EF 80-BF 80-BF
|
|
23
|
+
- F0 90-BF 80-BF 80-BF
|
|
24
|
+
- F1-F3 80-BF 80-BF 80-BF
|
|
25
|
+
- F4 80-8F 80-BF 80-BF
|
|
26
|
+
|
|
27
|
+
The regexp just tries to match this as compactly as possible.
|
|
28
|
+
*/
|
|
29
|
+
const validUtf8Encodings =
|
|
30
|
+
/%(?:[0-7]|(?!c[01]|e0%[89]|ed%[ab]|f0%8|f4%[9ab])(?:c|d|(?:e|f[0-4]%[89ab])[\da-f]%[89ab])[\da-f]%[89ab])[\da-f]/gi
|
|
31
|
+
|
|
32
|
+
export default function decodeURIComponentSafe(str: string): string {
|
|
33
|
+
return String(str).replace(validUtf8Encodings, decodeURIComponent)
|
|
34
|
+
}
|
package/util/hasOwn.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isomorphic next_tick utility
|
|
3
|
+
*
|
|
4
|
+
* Returns a Promise that resolves after the current execution stack completes.
|
|
5
|
+
*
|
|
6
|
+
* - In browser: Uses queueMicrotask for optimal performance, falls back to Promise.resolve()
|
|
7
|
+
* - In SSR: Resolves immediately since SSR rendering is synchronous and there's no event loop
|
|
8
|
+
*
|
|
9
|
+
* @returns Promise that resolves on the next tick
|
|
10
|
+
*/
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
12
|
+
async function next_tick(): Promise<void> {
|
|
13
|
+
// Check if we're in SSR mode
|
|
14
|
+
if (typeof globalThis !== 'undefined' && (globalThis as any).__SSR_MODE__) {
|
|
15
|
+
// In SSR mode, resolve immediately since SSR rendering is synchronous
|
|
16
|
+
// and there's no event loop to defer to
|
|
17
|
+
return Promise.resolve()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Browser mode: use queueMicrotask for optimal performance
|
|
21
|
+
if (typeof queueMicrotask !== 'undefined') {
|
|
22
|
+
return new Promise<void>((resolve) => {
|
|
23
|
+
queueMicrotask(resolve)
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fallback: use Promise.resolve() for older environments
|
|
28
|
+
if (typeof Promise !== 'undefined' && Promise.resolve) {
|
|
29
|
+
return Promise.resolve()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Last resort: setTimeout (shouldn't happen in modern environments)
|
|
33
|
+
if (typeof setTimeout !== 'undefined') {
|
|
34
|
+
return new Promise<void>((resolve: () => void) => {
|
|
35
|
+
setTimeout(resolve, 0)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// If nothing is available, resolve immediately
|
|
40
|
+
// This should never happen in practice, but TypeScript needs a return
|
|
41
|
+
return Promise.resolve() as Promise<void>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default next_tick
|
|
45
|
+
export {next_tick}
|