@dimina-kit/devkit 0.1.2-dev.20260711140249 → 0.1.2-dev.20260716153350

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/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { CompileLogEntry } from './compile-worker.js';
2
2
  import type { CompileWorkerStandby, CompileWorkerStandbyOptions } from './compile-worker-standby.js';
3
3
  export { WATCH_IGNORE_DIRS } from './watch-ignore.js';
4
+ export { isStyleOnlyChange, composeBuildCompleted } from './rebuild-dispatch.js';
4
5
  export { createRebuildScheduler } from './rebuild-scheduler.js';
5
6
  export type { RebuildScheduler } from './rebuild-scheduler.js';
6
7
  export { filterDmccLogLine } from './compile-log.js';
@@ -47,13 +48,26 @@ export interface OpenProjectOptions {
47
48
  /** When false, skip the chokidar file-watcher / auto-rebuild loop. Default true. */
48
49
  watch?: boolean;
49
50
  /**
50
- * When false, a completed watcher rebuild does NOT live-reload the preview
51
- * (the SSE `reload` container `window.location.reload()`); `onRebuild`
52
- * still fires so the host learns the build finished, but the page stack /
53
- * form state survives. Independent of `watch`. Default true.
51
+ * When false, a completed watcher rebuild does NOT touch the preview at all;
52
+ * `onRebuild` still fires so the host learns the build finished, but the page
53
+ * stack / form state survives. When true (default), the preview reacts to the
54
+ * rebuild: a rebuild touching ONLY stylesheets hot-swaps each `<link>` in
55
+ * place (SSE `reload-style` — page stack / form state survive), while any
56
+ * other change does a full page reload (SSE `reload` → container
57
+ * `window.location.reload()`). Independent of `watch`.
54
58
  */
55
59
  autoReload?: boolean;
56
- onRebuild?: () => void;
60
+ /**
61
+ * Fired after each successful watcher rebuild. `info.changedPaths` are the
62
+ * files that triggered it; `info.styleOnly` is true when every one is a
63
+ * stylesheet (see {@link isStyleOnlyChange}) — the host can then hot-swap
64
+ * styles in place instead of a full reload. Both are absent (undefined) for
65
+ * the SSE-driven web-preview path; native hosts read them to pick a fast path.
66
+ */
67
+ onRebuild?: (info?: {
68
+ changedPaths: string[];
69
+ styleOnly: boolean;
70
+ }) => void;
57
71
  onBuildError?: (err: unknown) => void;
58
72
  /**
59
73
  * Per-line dmcc compile log, already filtered through `filterDmccLogLine`
@@ -70,19 +84,6 @@ export interface OpenProjectOptions {
70
84
  */
71
85
  onWatcherError?: (err: unknown) => void;
72
86
  }
73
- /**
74
- * Compose the "build completed" subscriber — the single place deciding which of
75
- * two INDEPENDENT reactions run: live-reloading the preview, and the caller's
76
- * `onRebuild`. Reload is attached only when `autoReload` is on (so auto-compile
77
- * can stay ON while reload is OFF, preserving page/form state); when off,
78
- * `getReload` is not even called. `getReload` is read at call time because the
79
- * dev server's `reload` is assigned after this subscriber is wired.
80
- */
81
- export declare function composeBuildCompleted(opts: {
82
- autoReload: boolean;
83
- getReload: () => (() => void) | undefined;
84
- onRebuild?: () => void;
85
- }): () => void;
86
87
  export declare function openProject(opts: OpenProjectOptions): Promise<ProjectSession>;
87
88
  /**
88
89
  * Watch a project directory and invoke `onChange` whenever a source file is
@@ -90,7 +91,7 @@ export declare function openProject(opts: OpenProjectOptions): Promise<ProjectSe
90
91
  * watcher and whose `ready` resolves once the initial scan is complete (only
91
92
  * changes made after that point are guaranteed to surface).
92
93
  */
93
- export declare function createProjectWatcher(projectPath: string, onChange: () => void | Promise<void>, onWatcherError?: (err: unknown) => void): {
94
+ export declare function createProjectWatcher(projectPath: string, onChange: (changedPath?: string) => void | Promise<void>, onWatcherError?: (err: unknown) => void): {
94
95
  close: () => Promise<void>;
95
96
  ready: Promise<void>;
96
97
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -10,8 +10,10 @@ import { WATCH_IGNORE_DIRS } from './watch-ignore.js';
10
10
  import { applyEsbuildBinaryPath } from './esbuild-binary-path.js';
11
11
  import { createRebuildScheduler } from './rebuild-scheduler.js';
12
12
  import { createCompileWorker } from './compile-worker.js';
13
+ import { composeBuildCompleted, runRebuild } from './rebuild-dispatch.js';
13
14
  import { createCompileWorkerStandby } from './compile-worker-standby.js';
14
15
  export { WATCH_IGNORE_DIRS } from './watch-ignore.js';
16
+ export { isStyleOnlyChange, composeBuildCompleted } from './rebuild-dispatch.js';
15
17
  export { createRebuildScheduler } from './rebuild-scheduler.js';
16
18
  export { filterDmccLogLine } from './compile-log.js';
17
19
  export { createCompileWorker } from './compile-worker.js';
@@ -101,42 +103,6 @@ function resolveAppInfoFallback(projectPath) {
101
103
  return null;
102
104
  }
103
105
  }
104
- /** Insert or replace `rebuilt` in the session's app list, keyed by appId. */
105
- function upsertSessionApp(sessionApps, rebuilt) {
106
- const idx = sessionApps.findIndex(a => a.appId === rebuilt.appId);
107
- if (idx === -1)
108
- sessionApps.push(rebuilt);
109
- else
110
- sessionApps[idx] = rebuilt;
111
- }
112
- /**
113
- * Compose the "build completed" subscriber — the single place deciding which of
114
- * two INDEPENDENT reactions run: live-reloading the preview, and the caller's
115
- * `onRebuild`. Reload is attached only when `autoReload` is on (so auto-compile
116
- * can stay ON while reload is OFF, preserving page/form state); when off,
117
- * `getReload` is not even called. `getReload` is read at call time because the
118
- * dev server's `reload` is assigned after this subscriber is wired.
119
- */
120
- export function composeBuildCompleted(opts) {
121
- return () => {
122
- if (opts.autoReload)
123
- opts.getReload()?.();
124
- opts.onRebuild?.();
125
- };
126
- }
127
- /** Run one watcher-triggered rebuild, then fan out to the build-completed
128
- * subscriber (reload + onRebuild); a build failure routes to onBuildFailed. */
129
- async function runRebuild(compileWorker, buildRequest, sessionApps, onBuildCompleted, onBuildFailed) {
130
- try {
131
- const rebuilt = (await compileWorker.build(buildRequest));
132
- if (rebuilt)
133
- upsertSessionApp(sessionApps, rebuilt);
134
- onBuildCompleted();
135
- }
136
- catch (e) {
137
- onBuildFailed(e);
138
- }
139
- }
140
106
  /**
141
107
  * Tear down whatever partially started before `openProject` failed: the
142
108
  * watcher, the forked compile worker, and the dev server if it was already
@@ -238,6 +204,7 @@ export async function openProject(opts) {
238
204
  // propagates — otherwise every failed open leaks a whole compiler process.
239
205
  let server = null;
240
206
  let reload;
207
+ let reloadStyles;
241
208
  let watcher = null;
242
209
  try {
243
210
  process.env.DIMINA_NO_OPEN_BROWSER = '1';
@@ -253,17 +220,35 @@ export async function openProject(opts) {
253
220
  });
254
221
  server = started.server;
255
222
  reload = started.reload;
223
+ reloadStyles = started.reloadStyles;
256
224
  // Watcher events are routed through the scheduler so a save landing while
257
225
  // a build is in flight is never dropped: it coalesces into exactly one
258
226
  // trailing rebuild once the current run settles.
259
227
  const onBuildCompleted = composeBuildCompleted({
260
228
  autoReload,
261
229
  getReload: () => reload,
230
+ getReloadStyles: () => reloadStyles,
231
+ styleExts: fileTypes?.style,
262
232
  onRebuild,
263
233
  });
264
- const rebuildScheduler = createRebuildScheduler(() => runRebuild(compileWorker, buildRequest, sessionApps, onBuildCompleted, err => onBuildError?.(err)));
234
+ // The scheduler coalesces N saves into one trailing rebuild, so the changed
235
+ // paths accumulate here and are drained (not lost) at the moment that run
236
+ // actually starts. Draining at run-start — before the build's await — lets
237
+ // saves landing DURING the build accumulate cleanly for the next trailing
238
+ // run. An empty set means "changes unknown" → composeBuildCompleted falls
239
+ // back to a full reload rather than a style-only swap.
240
+ const pendingChanges = new Set();
241
+ const rebuildScheduler = createRebuildScheduler(() => {
242
+ const changed = [...pendingChanges];
243
+ pendingChanges.clear();
244
+ return runRebuild(compileWorker, buildRequest, sessionApps, onBuildCompleted, err => onBuildError?.(err), changed);
245
+ });
265
246
  watcher = watch
266
- ? createProjectWatcher(projectPath, () => rebuildScheduler.schedule(), onWatcherError)
247
+ ? createProjectWatcher(projectPath, (changedPath) => {
248
+ if (changedPath)
249
+ pendingChanges.add(changedPath);
250
+ rebuildScheduler.schedule();
251
+ }, onWatcherError)
267
252
  : null;
268
253
  // Don't resolve until the watcher's initial scan is done: a save landing
269
254
  // in the gap between `openProject` resolving and chokidar going live
@@ -346,9 +331,9 @@ export function createProjectWatcher(projectPath, onChange, onWatcherError) {
346
331
  // source file all surface as onChange. Listening only to 'change' silently
347
332
  // dropped the "developer added a new page" case (a chokidar 'add' event)
348
333
  // and made auto-compile feel broken on a common save pattern.
349
- watcher.on('add', () => { void onChange(); });
350
- watcher.on('change', () => { void onChange(); });
351
- watcher.on('unlink', () => { void onChange(); });
334
+ watcher.on('add', (p) => { void onChange(p); });
335
+ watcher.on('change', (p) => { void onChange(p); });
336
+ watcher.on('unlink', (p) => { void onChange(p); });
352
337
  // A watcher 'error' before the initial scan completes (EMFILE, permission
353
338
  // loss, …) must REJECT `ready`: resolving only on 'ready' would leave
354
339
  // `await watcher.ready` — and the whole openProject — hung forever. The
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=live-reload.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live-reload.test.d.ts","sourceRoot":"","sources":["../src/live-reload.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,115 @@
1
+ // @vitest-environment jsdom
2
+ import { EventEmitter } from 'node:events';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { createLiveReload, refreshStylesheets } from '../fe/live-reload.js';
5
+ function makeFakeRes() {
6
+ const chunks = [];
7
+ return {
8
+ setHeader: vi.fn(),
9
+ flushHeaders: vi.fn(),
10
+ write: vi.fn((chunk) => {
11
+ chunks.push(chunk);
12
+ }),
13
+ chunks,
14
+ };
15
+ }
16
+ function makeFakeReq() {
17
+ const emitter = new EventEmitter();
18
+ return {
19
+ on: (ev, cb) => {
20
+ emitter.on(ev, cb);
21
+ return emitter;
22
+ },
23
+ emit: (ev, ...args) => emitter.emit(ev, ...args),
24
+ };
25
+ }
26
+ describe('refreshStylesheets', () => {
27
+ it('appends a sibling link with a cache-busting __hmr param for a stylesheet link', () => {
28
+ document.head.innerHTML = '<link rel="stylesheet" href="/style.css">';
29
+ refreshStylesheets(document);
30
+ const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
31
+ const withHmr = links.filter((link) => link.href.includes('__hmr'));
32
+ expect(withHmr).toHaveLength(1);
33
+ });
34
+ it('does not touch non-stylesheet links', () => {
35
+ document.head.innerHTML = '<link rel="icon" href="/favicon.ico">';
36
+ refreshStylesheets(document);
37
+ const links = Array.from(document.querySelectorAll('link'));
38
+ expect(links).toHaveLength(1);
39
+ expect(links.some((link) => link.href.includes('__hmr'))).toBe(false);
40
+ });
41
+ it('recurses into a same-origin iframe and refreshes its inner stylesheet', () => {
42
+ document.body.innerHTML = '';
43
+ const iframe = document.createElement('iframe');
44
+ document.body.appendChild(iframe);
45
+ const innerDoc = iframe.contentDocument;
46
+ expect(innerDoc).toBeTruthy();
47
+ const innerLink = innerDoc.createElement('link');
48
+ innerLink.rel = 'stylesheet';
49
+ innerLink.href = 'x.css';
50
+ innerDoc.head.appendChild(innerLink);
51
+ refreshStylesheets(document);
52
+ const innerLinks = Array.from(innerDoc.querySelectorAll('link[rel="stylesheet"]'));
53
+ expect(innerLinks.some((link) => link.href.includes('__hmr'))).toBe(true);
54
+ });
55
+ it('does not throw on an empty document', () => {
56
+ document.head.innerHTML = '';
57
+ document.body.innerHTML = '';
58
+ expect(() => refreshStylesheets(document)).not.toThrow();
59
+ });
60
+ });
61
+ describe('createLiveReload', () => {
62
+ function setup() {
63
+ const routes = {};
64
+ const app = {
65
+ get: (p, h) => {
66
+ routes[p] = h;
67
+ },
68
+ };
69
+ const liveReload = createLiveReload(app);
70
+ return { routes, app, liveReload };
71
+ }
72
+ function connectClient(routes) {
73
+ const req = makeFakeReq();
74
+ const res = makeFakeRes();
75
+ const handler = routes['/__livereload'];
76
+ if (typeof handler !== 'function')
77
+ throw new Error('handler not registered');
78
+ handler(req, res);
79
+ return { req, res };
80
+ }
81
+ it('registers a GET handler for /__livereload', () => {
82
+ const { routes } = setup();
83
+ expect(routes['/__livereload']).toBeTypeOf('function');
84
+ });
85
+ it('reload() broadcasts an event: reload chunk to connected clients', () => {
86
+ const { routes, liveReload } = setup();
87
+ const { res } = connectClient(routes);
88
+ liveReload.reload();
89
+ expect(res.chunks.some((chunk) => chunk.includes('event: reload\n'))).toBe(true);
90
+ });
91
+ it('reloadStyles() broadcasts an event: reload-style chunk to connected clients', () => {
92
+ const { routes, liveReload } = setup();
93
+ const { res } = connectClient(routes);
94
+ liveReload.reloadStyles();
95
+ expect(res.chunks.some((chunk) => chunk.includes('event: reload-style\n'))).toBe(true);
96
+ });
97
+ it('stops writing to a client after its request closes', () => {
98
+ const { routes, liveReload } = setup();
99
+ const { req, res } = connectClient(routes);
100
+ req.emit('close');
101
+ res.chunks.length = 0;
102
+ liveReload.reload();
103
+ expect(res.write).not.toHaveBeenCalled();
104
+ expect(res.chunks).toHaveLength(0);
105
+ });
106
+ it('reload() only writes to still-connected clients, not a closed one, when multiple are connected', () => {
107
+ const { routes, liveReload } = setup();
108
+ const clientA = connectClient(routes);
109
+ const clientB = connectClient(routes);
110
+ clientA.req.emit('close');
111
+ liveReload.reload();
112
+ expect(clientA.res.chunks.some((chunk) => chunk.includes('event: reload\n'))).toBe(false);
113
+ expect(clientB.res.chunks.some((chunk) => chunk.includes('event: reload\n'))).toBe(true);
114
+ });
115
+ });
@@ -0,0 +1,41 @@
1
+ import type { AppInfo } from './index.js';
2
+ import type { BuildRequest, CompileWorker } from './compile-worker.js';
3
+ /** Insert or replace `rebuilt` in the session's app list, keyed by appId. */
4
+ export declare function upsertSessionApp(sessionApps: AppInfo[], rebuilt: AppInfo): void;
5
+ /**
6
+ * True iff `changedPaths` is non-empty AND every path is a stylesheet — the
7
+ * precondition for a style-only hot swap. Empty is false: "no known paths"
8
+ * (e.g. a coalesced rebuild that lost its change list) must fall back to a full
9
+ * reload rather than silently assume styles-only. `extraStyleExts` (from
10
+ * `fileTypes.style`) is merged in; a leading dot is optional and matching is
11
+ * case-insensitive.
12
+ */
13
+ export declare function isStyleOnlyChange(changedPaths: string[], extraStyleExts?: string[]): boolean;
14
+ /**
15
+ * Compose the "build completed" subscriber — the single place deciding which of
16
+ * two INDEPENDENT reactions run: reflecting the rebuild in the preview, and the
17
+ * caller's `onRebuild`. When `autoReload` is on and the rebuild touched ONLY
18
+ * stylesheets (`isStyleOnlyChange`), it hot-swaps the stylesheets in place
19
+ * (`getReloadStyles`) so the page stack / form state survives; otherwise it does
20
+ * a full `getReload()` page reload. With `autoReload` off, neither is called
21
+ * (auto-compile stays on, preview frozen). Both `getReload`/`getReloadStyles`
22
+ * are read at call time because the dev server assigns them after this
23
+ * subscriber is wired; `getReloadStyles` may be absent (older fe server) — then
24
+ * even a style-only change falls back to a full reload.
25
+ */
26
+ export declare function composeBuildCompleted(opts: {
27
+ autoReload: boolean;
28
+ getReload: () => (() => void) | undefined;
29
+ getReloadStyles?: () => (() => void) | undefined;
30
+ styleExts?: string[];
31
+ onRebuild?: (info?: {
32
+ changedPaths: string[];
33
+ styleOnly: boolean;
34
+ }) => void;
35
+ }): (changedPaths?: string[]) => void;
36
+ /** Run one watcher-triggered rebuild, then fan out to the build-completed
37
+ * subscriber (style hot-swap or full reload + onRebuild); a build failure routes
38
+ * to onBuildFailed. `changedPaths` are the files that triggered THIS rebuild —
39
+ * threaded so the subscriber can pick a style-only hot swap over a full reload. */
40
+ export declare function runRebuild(compileWorker: CompileWorker, buildRequest: BuildRequest, sessionApps: AppInfo[], onBuildCompleted: (changedPaths: string[]) => void, onBuildFailed: (err: unknown) => void, changedPaths?: string[]): Promise<void>;
41
+ //# sourceMappingURL=rebuild-dispatch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rebuild-dispatch.d.ts","sourceRoot":"","sources":["../src/rebuild-dispatch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AACzC,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAEtE,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAI/E;AAaD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,EAAE,EAAE,cAAc,GAAE,MAAM,EAAO,GAAG,OAAO,CAOhG;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE;IAC3C,UAAU,EAAE,OAAO,CAAA;IACnB,SAAS,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAA;IACzC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAA;IAChD,SAAS,CAAC,EAAE,MAAM,EAAE,CAAA;IACpB,SAAS,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,EAAE,MAAM,EAAE,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAA;CAC3E,GAAG,CAAC,YAAY,CAAC,EAAE,MAAM,EAAE,KAAK,IAAI,CAiBpC;AAED;;;mFAGmF;AACnF,wBAAsB,UAAU,CAC/B,aAAa,EAAE,aAAa,EAC5B,YAAY,EAAE,YAAY,EAC1B,WAAW,EAAE,OAAO,EAAE,EACtB,gBAAgB,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,IAAI,EAClD,aAAa,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,EACrC,YAAY,GAAE,MAAM,EAAO,GACzB,OAAO,CAAC,IAAI,CAAC,CASf"}
@@ -0,0 +1,79 @@
1
+ import path from 'node:path';
2
+ /** Insert or replace `rebuilt` in the session's app list, keyed by appId. */
3
+ export function upsertSessionApp(sessionApps, rebuilt) {
4
+ const idx = sessionApps.findIndex(a => a.appId === rebuilt.appId);
5
+ if (idx === -1)
6
+ sessionApps.push(rebuilt);
7
+ else
8
+ sessionApps[idx] = rebuilt;
9
+ }
10
+ /**
11
+ * Stylesheet extensions whose recompile can hot-swap in place (no full reload):
12
+ * the built-in `wx*`/`dd*` style dialects plus the CSS pre-processors dimina
13
+ * feeds through. A rebuild touching ONLY these can swap each `<link>` instead of
14
+ * reloading the page, because the compiled CSS scope id is a deterministic
15
+ * `hash(path)` — the recompiled `.css` keeps the same `[data-v-<id>]` selectors,
16
+ * so they still match the already-mounted DOM. Custom dialects (`.qdss`) are
17
+ * added per-project via `fileTypes.style`.
18
+ */
19
+ const DEFAULT_STYLE_EXTS = ['.wxss', '.ddss', '.css', '.less', '.scss', '.sass'];
20
+ /**
21
+ * True iff `changedPaths` is non-empty AND every path is a stylesheet — the
22
+ * precondition for a style-only hot swap. Empty is false: "no known paths"
23
+ * (e.g. a coalesced rebuild that lost its change list) must fall back to a full
24
+ * reload rather than silently assume styles-only. `extraStyleExts` (from
25
+ * `fileTypes.style`) is merged in; a leading dot is optional and matching is
26
+ * case-insensitive.
27
+ */
28
+ export function isStyleOnlyChange(changedPaths, extraStyleExts = []) {
29
+ if (changedPaths.length === 0)
30
+ return false;
31
+ const exts = new Set([...DEFAULT_STYLE_EXTS, ...extraStyleExts.map(e => (e.startsWith('.') ? e : `.${e}`))]
32
+ .map(e => e.toLowerCase()));
33
+ return changedPaths.every(p => exts.has(path.extname(p).toLowerCase()));
34
+ }
35
+ /**
36
+ * Compose the "build completed" subscriber — the single place deciding which of
37
+ * two INDEPENDENT reactions run: reflecting the rebuild in the preview, and the
38
+ * caller's `onRebuild`. When `autoReload` is on and the rebuild touched ONLY
39
+ * stylesheets (`isStyleOnlyChange`), it hot-swaps the stylesheets in place
40
+ * (`getReloadStyles`) so the page stack / form state survives; otherwise it does
41
+ * a full `getReload()` page reload. With `autoReload` off, neither is called
42
+ * (auto-compile stays on, preview frozen). Both `getReload`/`getReloadStyles`
43
+ * are read at call time because the dev server assigns them after this
44
+ * subscriber is wired; `getReloadStyles` may be absent (older fe server) — then
45
+ * even a style-only change falls back to a full reload.
46
+ */
47
+ export function composeBuildCompleted(opts) {
48
+ return (changedPaths = []) => {
49
+ const styleOnly = isStyleOnlyChange(changedPaths, opts.styleExts);
50
+ if (opts.autoReload) {
51
+ const reloadStyles = opts.getReloadStyles?.();
52
+ if (reloadStyles && styleOnly) {
53
+ reloadStyles();
54
+ }
55
+ else {
56
+ opts.getReload()?.();
57
+ }
58
+ }
59
+ // Hand the host the same style-only verdict the SSE dispatch used, so a
60
+ // native host (devtools simulator — no SSE client) can pick its OWN fast
61
+ // path (in-place stylesheet swap) instead of a full simulator respawn.
62
+ opts.onRebuild?.({ changedPaths, styleOnly });
63
+ };
64
+ }
65
+ /** Run one watcher-triggered rebuild, then fan out to the build-completed
66
+ * subscriber (style hot-swap or full reload + onRebuild); a build failure routes
67
+ * to onBuildFailed. `changedPaths` are the files that triggered THIS rebuild —
68
+ * threaded so the subscriber can pick a style-only hot swap over a full reload. */
69
+ export async function runRebuild(compileWorker, buildRequest, sessionApps, onBuildCompleted, onBuildFailed, changedPaths = []) {
70
+ try {
71
+ const rebuilt = (await compileWorker.build(buildRequest));
72
+ if (rebuilt)
73
+ upsertSessionApp(sessionApps, rebuilt);
74
+ onBuildCompleted(changedPaths);
75
+ }
76
+ catch (e) {
77
+ onBuildFailed(e);
78
+ }
79
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=style-hot-reload.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"style-hot-reload.test.d.ts","sourceRoot":"","sources":["../src/style-hot-reload.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { composeBuildCompleted, isStyleOnlyChange } from './index.js';
3
+ describe('isStyleOnlyChange', () => {
4
+ it('returns true when every changed path is a built-in stylesheet extension', () => {
5
+ expect(isStyleOnlyChange(['a.wxss', 'b.ddss', 'c.css', 'd.less', 'e.scss', 'f.sass'])).toBe(true);
6
+ });
7
+ it('returns false for an empty change set', () => {
8
+ expect(isStyleOnlyChange([])).toBe(false);
9
+ });
10
+ it('returns false for a mix of style and non-style files', () => {
11
+ expect(isStyleOnlyChange(['a.wxss', 'b.js'])).toBe(false);
12
+ });
13
+ it('returns false for a single non-style file', () => {
14
+ expect(isStyleOnlyChange(['app.js'])).toBe(false);
15
+ });
16
+ it('accepts extra style extensions without a leading dot', () => {
17
+ expect(isStyleOnlyChange(['page.qdss'], ['qdss'])).toBe(true);
18
+ });
19
+ it('accepts extra style extensions with a leading dot', () => {
20
+ expect(isStyleOnlyChange(['page.qdss'], ['.qdss'])).toBe(true);
21
+ });
22
+ it('is case-insensitive on both the path extension and the extra extension', () => {
23
+ expect(isStyleOnlyChange(['X.WXSS'])).toBe(true);
24
+ expect(isStyleOnlyChange(['page.QDSS'], ['qdss'])).toBe(true);
25
+ });
26
+ it('treats a path with no extension as not style-only', () => {
27
+ expect(isStyleOnlyChange(['Makefile'])).toBe(false);
28
+ });
29
+ it('only considers the extension, ignoring absolute vs relative path shape', () => {
30
+ expect(isStyleOnlyChange(['/a/b/page.wxss'])).toBe(true);
31
+ expect(isStyleOnlyChange(['page.js'])).toBe(false);
32
+ });
33
+ });
34
+ describe('composeBuildCompleted', () => {
35
+ it('when autoReload is false, calls neither reload nor reloadStyles, but still calls onRebuild', () => {
36
+ const reload = vi.fn();
37
+ const reloadStyles = vi.fn();
38
+ const onRebuild = vi.fn();
39
+ const fn = composeBuildCompleted({
40
+ autoReload: false,
41
+ getReload: () => reload,
42
+ getReloadStyles: () => reloadStyles,
43
+ onRebuild,
44
+ });
45
+ fn(['a.wxss']);
46
+ expect(reload).not.toHaveBeenCalled();
47
+ expect(reloadStyles).not.toHaveBeenCalled();
48
+ expect(onRebuild).toHaveBeenCalledTimes(1);
49
+ });
50
+ it('when autoReload is true and change is style-only with reloadStyles available, hot-swaps styles only', () => {
51
+ const reload = vi.fn();
52
+ const reloadStyles = vi.fn();
53
+ const fn = composeBuildCompleted({
54
+ autoReload: true,
55
+ getReload: () => reload,
56
+ getReloadStyles: () => reloadStyles,
57
+ });
58
+ fn(['a.wxss', 'b.css']);
59
+ expect(reloadStyles).toHaveBeenCalledTimes(1);
60
+ expect(reload).not.toHaveBeenCalled();
61
+ });
62
+ it('when style-only but reloadStyles is absent, falls back to a full reload', () => {
63
+ const reload = vi.fn();
64
+ const fn = composeBuildCompleted({
65
+ autoReload: true,
66
+ getReload: () => reload,
67
+ getReloadStyles: () => undefined,
68
+ });
69
+ fn(['a.wxss']);
70
+ expect(reload).toHaveBeenCalledTimes(1);
71
+ });
72
+ it('when the change is not style-only, does a full reload and does not call reloadStyles', () => {
73
+ const reload = vi.fn();
74
+ const reloadStyles = vi.fn();
75
+ const fn = composeBuildCompleted({
76
+ autoReload: true,
77
+ getReload: () => reload,
78
+ getReloadStyles: () => reloadStyles,
79
+ });
80
+ fn(['a.wxss', 'b.js']);
81
+ expect(reload).toHaveBeenCalledTimes(1);
82
+ expect(reloadStyles).not.toHaveBeenCalled();
83
+ });
84
+ it('calls onRebuild on every invocation regardless of autoReload', () => {
85
+ const onRebuild = vi.fn();
86
+ const reload = vi.fn();
87
+ const fnOff = composeBuildCompleted({
88
+ autoReload: false,
89
+ getReload: () => reload,
90
+ onRebuild,
91
+ });
92
+ fnOff(['a.js']);
93
+ const fnOn = composeBuildCompleted({
94
+ autoReload: true,
95
+ getReload: () => reload,
96
+ onRebuild,
97
+ });
98
+ fnOn(['a.js']);
99
+ expect(onRebuild).toHaveBeenCalledTimes(2);
100
+ });
101
+ it('honors custom styleExts so a project dialect file hot-swaps too', () => {
102
+ const reload = vi.fn();
103
+ const reloadStyles = vi.fn();
104
+ const fn = composeBuildCompleted({
105
+ autoReload: true,
106
+ getReload: () => reload,
107
+ getReloadStyles: () => reloadStyles,
108
+ styleExts: ['qdss'],
109
+ });
110
+ fn(['theme.qdss']);
111
+ expect(reloadStyles).toHaveBeenCalledTimes(1);
112
+ expect(reload).not.toHaveBeenCalled();
113
+ });
114
+ it('when called with no changedPaths, treats it as empty and does a full reload', () => {
115
+ const reload = vi.fn();
116
+ const reloadStyles = vi.fn();
117
+ const fn = composeBuildCompleted({
118
+ autoReload: true,
119
+ getReload: () => reload,
120
+ getReloadStyles: () => reloadStyles,
121
+ });
122
+ fn();
123
+ expect(reload).toHaveBeenCalledTimes(1);
124
+ expect(reloadStyles).not.toHaveBeenCalled();
125
+ });
126
+ it('reads getReload/getReloadStyles at call time, not at compose time', () => {
127
+ let currentReload = vi.fn();
128
+ const fn = composeBuildCompleted({
129
+ autoReload: true,
130
+ getReload: () => currentReload,
131
+ });
132
+ const replacedReload = vi.fn();
133
+ currentReload = replacedReload;
134
+ fn(['a.js']);
135
+ expect(replacedReload).toHaveBeenCalledTimes(1);
136
+ });
137
+ });
138
+ describe('composeBuildCompleted: onRebuild receives the style-only verdict', () => {
139
+ it('an all-.wxss change set reports styleOnly:true with the original changedPaths', () => {
140
+ const onRebuild = vi.fn();
141
+ const reload = vi.fn();
142
+ const changedPaths = ['a.wxss', 'b.wxss'];
143
+ const fn = composeBuildCompleted({
144
+ autoReload: false,
145
+ getReload: () => reload,
146
+ onRebuild,
147
+ });
148
+ fn(changedPaths);
149
+ expect(onRebuild.mock.calls[0][0]).toEqual({ changedPaths, styleOnly: true });
150
+ });
151
+ it('a mixed .wxss + .js change set reports styleOnly:false', () => {
152
+ const onRebuild = vi.fn();
153
+ const reload = vi.fn();
154
+ const fn = composeBuildCompleted({
155
+ autoReload: false,
156
+ getReload: () => reload,
157
+ onRebuild,
158
+ });
159
+ fn(['a.wxss', 'a.js']);
160
+ expect(onRebuild.mock.calls[0][0].styleOnly).toBe(false);
161
+ });
162
+ it('a custom styleExts-only change (e.g. .qdss) reports styleOnly:true', () => {
163
+ const onRebuild = vi.fn();
164
+ const reload = vi.fn();
165
+ const fn = composeBuildCompleted({
166
+ autoReload: false,
167
+ getReload: () => reload,
168
+ styleExts: ['qdss'],
169
+ onRebuild,
170
+ });
171
+ fn(['theme.qdss']);
172
+ expect(onRebuild.mock.calls[0][0].styleOnly).toBe(true);
173
+ });
174
+ it('called with no changedPaths reports styleOnly:false and an empty changedPaths array', () => {
175
+ const onRebuild = vi.fn();
176
+ const reload = vi.fn();
177
+ const fn = composeBuildCompleted({
178
+ autoReload: false,
179
+ getReload: () => reload,
180
+ onRebuild,
181
+ });
182
+ fn();
183
+ expect(onRebuild.mock.calls[0][0]).toEqual({ changedPaths: [], styleOnly: false });
184
+ });
185
+ });