sygnal 5.2.0 → 5.3.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.
@@ -0,0 +1,179 @@
1
+ import xs, {Stream} from 'xstream';
2
+ import {adapt} from '../cycle/run/adapt';
3
+
4
+ // ── Types ────────────────────────────────────────────────────
5
+
6
+ export interface ServiceWorkerSource {
7
+ select(type?: string): any;
8
+ }
9
+
10
+ export interface ServiceWorkerCommand {
11
+ action: 'skipWaiting' | 'postMessage' | 'unregister';
12
+ data?: any;
13
+ }
14
+
15
+ export interface ServiceWorkerOptions {
16
+ scope?: string;
17
+ }
18
+
19
+ export interface InstallPrompt {
20
+ select(type: 'beforeinstallprompt' | 'appinstalled'): any;
21
+ prompt(): Promise<any> | undefined;
22
+ }
23
+
24
+ // ── makeServiceWorkerDriver ──────────────────────────────────
25
+
26
+ function trackWorker(worker: ServiceWorker, events: EventTarget) {
27
+ const emit = (type: string, data: any) =>
28
+ events.dispatchEvent(new CustomEvent('data', {detail: {type, data}}));
29
+
30
+ worker.addEventListener('statechange', () => {
31
+ if (worker.state === 'installed') emit('installed', true);
32
+ if (worker.state === 'activated') emit('activated', true);
33
+ });
34
+
35
+ if (worker.state === 'installed') emit('waiting', worker);
36
+ if (worker.state === 'activated') emit('activated', true);
37
+ }
38
+
39
+ export function makeServiceWorkerDriver(
40
+ scriptUrl: string,
41
+ options: ServiceWorkerOptions = {},
42
+ ): (sink$: Stream<ServiceWorkerCommand>) => ServiceWorkerSource {
43
+ return function serviceWorkerDriver(sink$: Stream<ServiceWorkerCommand>): ServiceWorkerSource {
44
+ const events = new EventTarget();
45
+
46
+ if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
47
+ navigator.serviceWorker
48
+ .register(scriptUrl, {scope: options.scope})
49
+ .then((reg) => {
50
+ const emit = (type: string, data: any) =>
51
+ events.dispatchEvent(new CustomEvent('data', {detail: {type, data}}));
52
+
53
+ if (reg.installing) trackWorker(reg.installing, events);
54
+ if (reg.waiting) emit('waiting', reg.waiting);
55
+ if (reg.active) emit('activated', true);
56
+
57
+ reg.addEventListener('updatefound', () => {
58
+ if (reg.installing) trackWorker(reg.installing, events);
59
+ });
60
+
61
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
62
+ emit('controlling', true);
63
+ });
64
+
65
+ navigator.serviceWorker.addEventListener('message', (e) => {
66
+ emit('message', (e as MessageEvent).data);
67
+ });
68
+ })
69
+ .catch((err) => {
70
+ events.dispatchEvent(new CustomEvent('data', {detail: {type: 'error', data: err}}));
71
+ });
72
+
73
+ sink$.addListener({
74
+ next: (cmd: ServiceWorkerCommand) => {
75
+ if (cmd.action === 'skipWaiting') {
76
+ navigator.serviceWorker.ready.then((r) => {
77
+ if (r.waiting) r.waiting.postMessage({type: 'SKIP_WAITING'});
78
+ });
79
+ } else if (cmd.action === 'postMessage') {
80
+ navigator.serviceWorker.ready.then((r) => {
81
+ if (r.active) r.active.postMessage(cmd.data);
82
+ });
83
+ } else if (cmd.action === 'unregister') {
84
+ navigator.serviceWorker.ready.then((r) => r.unregister());
85
+ }
86
+ },
87
+ error: (err: any) => console.error('[SW driver] Error in sink stream:', err),
88
+ });
89
+ }
90
+
91
+ return {
92
+ select(type?: string) {
93
+ let cb: ((e: Event) => void) | undefined;
94
+ const in$ = xs.create<any>({
95
+ start: (listener) => {
96
+ cb = ({detail}: any) => {
97
+ if (!type || detail.type === type) listener.next(detail.data);
98
+ };
99
+ events.addEventListener('data', cb);
100
+ },
101
+ stop: () => {
102
+ if (cb) events.removeEventListener('data', cb);
103
+ },
104
+ });
105
+ return adapt(in$);
106
+ },
107
+ };
108
+ };
109
+ }
110
+
111
+ // ── onlineStatus$ ────────────────────────────────────────────
112
+
113
+ export function onlineStatus$(): Stream<boolean> {
114
+ if (typeof window === 'undefined') {
115
+ return xs.of(true);
116
+ }
117
+
118
+ let cleanup: (() => void) | undefined;
119
+
120
+ return xs.create<boolean>({
121
+ start(listener) {
122
+ listener.next(navigator.onLine);
123
+ const on = () => listener.next(true);
124
+ const off = () => listener.next(false);
125
+ window.addEventListener('online', on);
126
+ window.addEventListener('offline', off);
127
+ cleanup = () => {
128
+ window.removeEventListener('online', on);
129
+ window.removeEventListener('offline', off);
130
+ };
131
+ },
132
+ stop() {
133
+ cleanup?.();
134
+ cleanup = undefined;
135
+ },
136
+ });
137
+ }
138
+
139
+ // ── createInstallPrompt ──────────────────────────────────────
140
+
141
+ export function createInstallPrompt(): InstallPrompt {
142
+ let deferredPrompt: any = null;
143
+ const events = new EventTarget();
144
+
145
+ if (typeof window !== 'undefined') {
146
+ window.addEventListener('beforeinstallprompt', (e) => {
147
+ e.preventDefault();
148
+ deferredPrompt = e;
149
+ events.dispatchEvent(new CustomEvent('data', {detail: {type: 'beforeinstallprompt', data: true}}));
150
+ });
151
+
152
+ window.addEventListener('appinstalled', () => {
153
+ deferredPrompt = null;
154
+ events.dispatchEvent(new CustomEvent('data', {detail: {type: 'appinstalled', data: true}}));
155
+ });
156
+ }
157
+
158
+ return {
159
+ select(type: 'beforeinstallprompt' | 'appinstalled') {
160
+ let cb: ((e: Event) => void) | undefined;
161
+ const in$ = xs.create<any>({
162
+ start: (listener) => {
163
+ cb = ({detail}: any) => {
164
+ if (detail.type === type) listener.next(detail.data);
165
+ };
166
+ events.addEventListener('data', cb);
167
+ },
168
+ stop: () => {
169
+ if (cb) events.removeEventListener('data', cb);
170
+ },
171
+ });
172
+ return adapt(in$);
173
+ },
174
+
175
+ prompt() {
176
+ return deferredPrompt?.prompt();
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Reducer helpers for common state update patterns.
3
+ *
4
+ * These reduce boilerplate in model definitions by providing
5
+ * shorthand factories for the most frequent reducer shapes.
6
+ */
7
+
8
+ // ── set() ──────────────────────────────────────────────────────────
9
+ /**
10
+ * Create a reducer that merges a partial update into state.
11
+ *
12
+ * Static form — merge a fixed object:
13
+ * set({ isEditing: true })
14
+ *
15
+ * Dynamic form — function receives (state, data, next, props) and
16
+ * returns the partial update to merge:
17
+ * set((state, title) => ({ title }))
18
+ */
19
+ export function set<S = any>(
20
+ partial: Partial<S> | ((state: S, data: any, next: Function, props: any) => Partial<S>)
21
+ ): (state: S, data: any, next: Function, props: any) => S {
22
+ if (typeof partial === 'function') {
23
+ return (state, data, next, props) => ({
24
+ ...state,
25
+ ...partial(state, data, next, props),
26
+ })
27
+ }
28
+ return (state) => ({ ...state, ...partial })
29
+ }
30
+
31
+ // ── toggle() ───────────────────────────────────────────────────────
32
+ /**
33
+ * Create a reducer that toggles a boolean field on state.
34
+ *
35
+ * toggle('showModal')
36
+ * // equivalent to: (state) => ({ ...state, showModal: !state.showModal })
37
+ */
38
+ export function toggle<S = any>(field: keyof S & string): (state: S) => S {
39
+ return (state) => ({ ...state, [field]: !state[field] })
40
+ }
41
+
42
+ // ── emit() ─────────────────────────────────────────────────────────
43
+ /**
44
+ * Create a model entry that emits an EVENTS bus event.
45
+ *
46
+ * With static data:
47
+ * emit('DELETE_LANE', { laneId: 42 })
48
+ *
49
+ * With dynamic data derived from state:
50
+ * emit('DELETE_LANE', (state) => ({ laneId: state.id }))
51
+ *
52
+ * Fire-and-forget (no data):
53
+ * emit('REFRESH')
54
+ */
55
+ export function emit(
56
+ type: string,
57
+ data?: any | ((state: any, actionData: any, next: Function, props: any) => any)
58
+ ): { EVENTS: (state: any, actionData: any, next: Function, props: any) => { type: string; data: any } } {
59
+ return {
60
+ EVENTS: typeof data === 'function'
61
+ ? (state, actionData, next, props) => ({ type, data: data(state, actionData, next, props) })
62
+ : () => ({ type, data }),
63
+ }
64
+ }
package/src/extra/ssr.ts CHANGED
@@ -193,6 +193,25 @@ function processSSRTree(vnode: any, context: Record<string, any>, parentState?:
193
193
  }
194
194
  }
195
195
 
196
+ // ClientOnly: render fallback during SSR, skip children (they need a browser)
197
+ if (sel === 'clientonly') {
198
+ const props = vnode.data?.props || {}
199
+ const fallback = props.fallback
200
+ if (fallback) {
201
+ // fallback can be a VNode or a string
202
+ return processSSRTree(fallback, context, parentState)
203
+ }
204
+ // No fallback — render an empty placeholder div
205
+ return {
206
+ sel: 'div',
207
+ data: {attrs: {'data-sygnal-clientonly': ''}},
208
+ children: [],
209
+ text: undefined,
210
+ elm: undefined,
211
+ key: undefined,
212
+ }
213
+ }
214
+
196
215
  // Slot: unwrap to children
197
216
  if (sel === 'slot') {
198
217
  const children = vnode.children || []
package/src/index.d.ts CHANGED
@@ -378,6 +378,40 @@ export function enableHMR<STATE = any, DRIVERS = {}>(
378
378
  export function classes(...classes: ClassesType): string
379
379
  export function exactState<STATE>(): <ACTUAL extends STATE>(state: ExactShape<STATE, ACTUAL>) => STATE
380
380
 
381
+ // ── Reducer helpers ────────────────────────────────────────────────
382
+
383
+ /**
384
+ * Create a reducer that merges a partial update into state.
385
+ *
386
+ * Static form — merge a fixed object:
387
+ * `set({ isEditing: true })`
388
+ *
389
+ * Dynamic form — function receives (state, data, next, props) and
390
+ * returns the partial update to merge:
391
+ * `set((state, title) => ({ title }))`
392
+ */
393
+ export function set<S = any>(
394
+ partial: Partial<S> | ((state: S, data: any, next: Function, props: any) => Partial<S>)
395
+ ): (state: S, data: any, next: Function, props: any) => S
396
+
397
+ /**
398
+ * Create a reducer that toggles a boolean field on state.
399
+ *
400
+ * `toggle('showModal')`
401
+ */
402
+ export function toggle<S = any>(field: keyof S & string): (state: S) => S
403
+
404
+ /**
405
+ * Create a model entry that emits an EVENTS bus event.
406
+ *
407
+ * `emit('DELETE_LANE', (state) => ({ laneId: state.id }))`
408
+ * `emit('REFRESH')`
409
+ */
410
+ export function emit(
411
+ type: string,
412
+ data?: any | ((state: any, actionData: any, next: Function, props: any) => any)
413
+ ): { EVENTS: (state: any, actionData: any, next: Function, props: any) => { type: string; data: any } }
414
+
381
415
  /**
382
416
  * Any object with an events() method (e.g., DOM.select('form')).
383
417
  * Uses permissive signature to be compatible with MainDOMSource's overloaded events().
@@ -561,6 +595,35 @@ export interface CommandSource {
561
595
 
562
596
  export function createCommand(): Command
563
597
 
598
+ // ── PWA Helpers ──────────────────────────────────────────────
599
+
600
+ export interface ServiceWorkerSource {
601
+ select(type?: 'installed' | 'activated' | 'waiting' | 'controlling' | 'error' | 'message' | string): Stream<any>;
602
+ }
603
+
604
+ export interface ServiceWorkerCommand {
605
+ action: 'skipWaiting' | 'postMessage' | 'unregister';
606
+ data?: any;
607
+ }
608
+
609
+ export interface ServiceWorkerOptions {
610
+ scope?: string;
611
+ }
612
+
613
+ export function makeServiceWorkerDriver(
614
+ scriptUrl: string,
615
+ options?: ServiceWorkerOptions
616
+ ): (sink$: Stream<ServiceWorkerCommand>) => ServiceWorkerSource
617
+
618
+ export function onlineStatus$(): Stream<boolean>
619
+
620
+ export interface InstallPrompt {
621
+ select(type: 'beforeinstallprompt' | 'appinstalled'): Stream<any>;
622
+ prompt(): Promise<any> | undefined;
623
+ }
624
+
625
+ export function createInstallPrompt(): InstallPrompt
626
+
564
627
  export interface RenderOptions {
565
628
  /** Override initial state (defaults to component's .initialState) */
566
629
  initialState?: any;
package/src/index.ts CHANGED
@@ -21,6 +21,8 @@ export { createElement } from './pragma/index'
21
21
  export { createCommand } from './extra/command'
22
22
  export { createRef, createRef$ } from './extra/ref'
23
23
  export { renderComponent } from './extra/testing'
24
+ export { set, toggle, emit } from './extra/reducers'
25
+ export { makeServiceWorkerDriver, onlineStatus$, createInstallPrompt } from './extra/pwa'
24
26
  export { renderToString } from './extra/ssr'
25
27
  export { default as xs } from './extra/xstreamCompat'
26
28
  export { getDevTools } from './extra/devtools'
@@ -15,13 +15,17 @@ export default {
15
15
  onRenderHtml: 'import:sygnal/vike/onRenderHtml:onRenderHtml',
16
16
  onRenderClient: 'import:sygnal/vike/onRenderClient:onRenderClient',
17
17
 
18
- passToClient: ['data', 'routeParams'],
18
+ passToClient: ['data', 'routeParams', 'urlPathname'],
19
19
 
20
20
  meta: {
21
21
  Layout: {
22
22
  env: { server: true, client: true },
23
23
  cumulative: true,
24
24
  },
25
+ Wrapper: {
26
+ env: { server: true, client: true },
27
+ cumulative: true,
28
+ },
25
29
  Head: {
26
30
  env: { server: true },
27
31
  },
@@ -0,0 +1,10 @@
1
+ import {h} from '../cycle/dom/snabbdom';
2
+
3
+ const ClientOnly = (props: any) => {
4
+ const {children, ...sanitizedProps} = props;
5
+ return h('clientonly', {props: sanitizedProps}, children);
6
+ };
7
+ (ClientOnly as any).label = 'clientonly';
8
+ (ClientOnly as any).preventInstantiation = true;
9
+
10
+ export {ClientOnly};