@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 +20 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -41
- package/dist/live-reload.test.d.ts +2 -0
- package/dist/live-reload.test.d.ts.map +1 -0
- package/dist/live-reload.test.js +115 -0
- package/dist/rebuild-dispatch.d.ts +41 -0
- package/dist/rebuild-dispatch.d.ts.map +1 -0
- package/dist/rebuild-dispatch.js +79 -0
- package/dist/style-hot-reload.test.d.ts +2 -0
- package/dist/style-hot-reload.test.d.ts.map +1 -0
- package/dist/style-hot-reload.test.js +185 -0
- package/fe/dimina-fe-container/assets/pageFrame.js +4 -4
- package/fe/dimina-fe-container/assets/service.js +2 -2
- package/fe/dimina-fe-container/dimina-version.json +2 -2
- package/fe/index.js +2 -2
- package/fe/live-reload.js +64 -5
- package/package.json +4 -4
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
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
|
};
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,
|
|
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
|
-
|
|
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, () =>
|
|
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 @@
|
|
|
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 @@
|
|
|
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
|
+
});
|