@rettangoli/fe 1.2.1 → 1.2.3

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuusoft
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the “Software”), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software
10
+ is furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included
13
+ in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
+ IN THE SOFTWARE.
package/README.md CHANGED
@@ -95,6 +95,7 @@ src/
95
95
 
96
96
  - `rtgl fe build` uses Vite 8's Rolldown-powered `vite.build()` with a virtual entry module generated from configured component files.
97
97
  - `rtgl fe watch` uses `vite.createServer()`, warms the virtual entry during startup, and serves the configured `outfile` path via middleware.
98
+ - Watch projects can configure `fe.publicDir` to serve static assets from the project root without Vite transformations.
98
99
  - FE runtime source is generated in memory (virtual module), so no temporary generated JS files are required.
99
100
  - Contract validation, YAML parsing, and template parsing still run before code generation.
100
101
  - Component directories, setup files, and locale files are registered explicitly so watch mode also sees sources outside the served static root.
@@ -125,6 +126,7 @@ fe:
125
126
  - "./src/pages"
126
127
  setup: "setup.js"
127
128
  outfile: "./dist/bundle.js"
129
+ publicDir: "./static"
128
130
  i18n:
129
131
  dir: "./src/i18n"
130
132
  defaultLocale: "en"
@@ -136,6 +138,10 @@ fe:
136
138
  outputDir: "./vt/specs/examples"
137
139
  ```
138
140
 
141
+ `publicDir` is optional and only affects `rtgl fe watch`. Its files are served
142
+ at `/` without Vite transformations. This is useful for extensionless or other
143
+ binary assets that must retain their source filenames.
144
+
139
145
  ## Setup Contract
140
146
 
141
147
  `setup.js` should export `deps` only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/fe",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Frontend framework for building reactive web components",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -44,6 +44,7 @@
44
44
  "scripts": {
45
45
  "dev": "node ../rettangoli-cli/cli.js fe watch",
46
46
  "test": "vitest run --reporter=verbose",
47
+ "test:package": "node test/package-smoke-test.js",
47
48
  "test:puty": "vitest run puty.spec.js --reporter=verbose",
48
49
  "test:vitest": "vitest run test/**/*.test.js --reporter=verbose"
49
50
  }
package/src/cli/watch.js CHANGED
@@ -43,13 +43,19 @@ export const createWatchServer = async (options = {}) => {
43
43
  outfile = "./vt/static/main.js",
44
44
  setup = "setup.js",
45
45
  i18n = null,
46
+ publicDir,
46
47
  } = options;
47
48
 
48
49
  const { root, publicEntryPath } = resolveServeContext({ cwd, outfile });
50
+ const resolvedPublicDir =
51
+ typeof publicDir === "string" && publicDir.length > 0
52
+ ? path.resolve(cwd, publicDir)
53
+ : publicDir;
49
54
 
50
55
  const server = await createServer({
51
56
  clearScreen: false,
52
57
  configFile: false,
58
+ publicDir: resolvedPublicDir,
53
59
  root,
54
60
  server: {
55
61
  port,
@@ -1,42 +1,306 @@
1
1
  import { scheduleFrame } from "./scheduler.js";
2
2
 
3
+ export const RETTANGOLI_COMPONENT_MARKER = Symbol.for(
4
+ "@rettangoli/fe/component",
5
+ );
6
+
7
+ const propsSnapshots = new WeakMap();
8
+ const pendingUpdates = new WeakMap();
9
+
10
+ const createReferenceSnapshot = (value) => ({
11
+ value,
12
+ hasStructuralSnapshot: false,
13
+ });
14
+
15
+ const hasUnsupportedToJSONOrPrototypeCycle = (value) => {
16
+ const visited = new Set();
17
+ let current = value;
18
+
19
+ while (current !== null) {
20
+ if (visited.has(current)) return true;
21
+ visited.add(current);
22
+
23
+ const descriptor = Object.getOwnPropertyDescriptor(current, "toJSON");
24
+ if (descriptor) {
25
+ if (!("value" in descriptor)) return true;
26
+ return typeof descriptor.value === "function";
27
+ }
28
+ current = Object.getPrototypeOf(current);
29
+ }
30
+
31
+ return false;
32
+ };
33
+
34
+ const isJsonData = (value, ancestors = new Set()) => {
35
+ if (value === null) return true;
36
+
37
+ const valueType = typeof value;
38
+ if (valueType === "string" || valueType === "boolean") return true;
39
+ if (valueType === "number") {
40
+ return Number.isFinite(value) && !Object.is(value, -0);
41
+ }
42
+ if (valueType !== "object") return false;
43
+
44
+ if (ancestors.has(value)) return false;
45
+
46
+ const isArray = Array.isArray(value);
47
+ const prototype = Object.getPrototypeOf(value);
48
+ if (
49
+ (isArray && prototype !== Array.prototype) ||
50
+ (!isArray && prototype !== Object.prototype && prototype !== null)
51
+ ) {
52
+ return false;
53
+ }
54
+
55
+ if (hasUnsupportedToJSONOrPrototypeCycle(value)) return false;
56
+
57
+ ancestors.add(value);
58
+ try {
59
+ if (isArray) {
60
+ const ownKeys = Reflect.ownKeys(value);
61
+ if (ownKeys.length !== value.length + 1 || !ownKeys.includes("length")) {
62
+ return false;
63
+ }
64
+
65
+ for (let index = 0; index < value.length; index += 1) {
66
+ const descriptor = Object.getOwnPropertyDescriptor(
67
+ value,
68
+ String(index),
69
+ );
70
+ if (
71
+ !descriptor ||
72
+ !("value" in descriptor) ||
73
+ !descriptor.enumerable ||
74
+ !isJsonData(descriptor.value, ancestors)
75
+ ) {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ return true;
81
+ }
82
+
83
+ for (const key of Reflect.ownKeys(value)) {
84
+ if (typeof key !== "string") return false;
85
+
86
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
87
+ if (
88
+ !descriptor ||
89
+ !("value" in descriptor) ||
90
+ !descriptor.enumerable ||
91
+ !isJsonData(descriptor.value, ancestors)
92
+ ) {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ return true;
98
+ } finally {
99
+ ancestors.delete(value);
100
+ }
101
+ };
102
+
103
+ const createPropSnapshot = (value) => {
104
+ if (value === null || typeof value !== "object") {
105
+ return createReferenceSnapshot(value);
106
+ }
107
+
108
+ try {
109
+ if (!isJsonData(value)) {
110
+ return createReferenceSnapshot(value);
111
+ }
112
+
113
+ // Keep the live value for identity and a separate old value only for
114
+ // detecting and reporting same-reference JSON-data mutations.
115
+ const serialized = JSON.stringify(value);
116
+ if (serialized === undefined || typeof structuredClone !== "function") {
117
+ return createReferenceSnapshot(value);
118
+ }
119
+ return {
120
+ value,
121
+ hasStructuralSnapshot: true,
122
+ serialized,
123
+ oldValue: structuredClone(value),
124
+ };
125
+ } catch {
126
+ return createReferenceSnapshot(value);
127
+ }
128
+ };
129
+
130
+ const createPropsSnapshot = (props = {}) => {
131
+ const entries = new Map();
132
+ Object.keys(props).forEach((key) => {
133
+ entries.set(key, createPropSnapshot(props[key]));
134
+ });
135
+ return entries;
136
+ };
137
+
138
+ const propChanged = (previousEntry, nextEntry) => {
139
+ if (!previousEntry || !nextEntry) return true;
140
+ if (previousEntry.hasStructuralSnapshot !== nextEntry.hasStructuralSnapshot) {
141
+ return true;
142
+ }
143
+ if (previousEntry.hasStructuralSnapshot) {
144
+ return previousEntry.serialized !== nextEntry.serialized;
145
+ }
146
+ return !Object.is(previousEntry.value, nextEntry.value);
147
+ };
148
+
149
+ const getChangedPropKeys = (previousSnapshot, nextSnapshot) => {
150
+ const keys = new Set([...previousSnapshot.keys(), ...nextSnapshot.keys()]);
151
+ return [...keys].filter((key) =>
152
+ propChanged(previousSnapshot.get(key), nextSnapshot.get(key)),
153
+ );
154
+ };
155
+
156
+ const defineProp = (target, key, value) => {
157
+ Object.defineProperty(target, key, {
158
+ configurable: true,
159
+ enumerable: true,
160
+ value,
161
+ writable: true,
162
+ });
163
+ };
164
+
165
+ const hasDriftedFromSnapshot = (entry) => {
166
+ if (!entry.hasStructuralSnapshot) return false;
167
+ try {
168
+ if (!isJsonData(entry.value)) return true;
169
+ return JSON.stringify(entry.value) !== entry.serialized;
170
+ } catch {
171
+ return true;
172
+ }
173
+ };
174
+
175
+ const createOldProps = (previousSnapshot) => {
176
+ const oldProps = {};
177
+ previousSnapshot.forEach((entry, key) => {
178
+ defineProp(
179
+ oldProps,
180
+ key,
181
+ hasDriftedFromSnapshot(entry) ? entry.oldValue : entry.value,
182
+ );
183
+ });
184
+ return oldProps;
185
+ };
186
+
187
+ const isRettangoliComponent = (element) => {
188
+ try {
189
+ if (element?.[RETTANGOLI_COMPONENT_MARKER] === true) {
190
+ return true;
191
+ }
192
+
193
+ // Compatibility for separately bundled components built before the brand
194
+ // existed. These relationships are installed together by the FE runtime.
195
+ return (
196
+ typeof element?.elementName === "string" &&
197
+ typeof element.render === "function" &&
198
+ typeof element.patch === "function" &&
199
+ typeof element._snabbdomH === "function" &&
200
+ element.deps?.render === element.render &&
201
+ element.deps?.store === element.store &&
202
+ element.deps?.props === element.props
203
+ );
204
+ } catch {
205
+ return false;
206
+ }
207
+ };
208
+
209
+ const storePropsSnapshot = (element, props = {}) => {
210
+ const snapshot = createPropsSnapshot(props);
211
+ propsSnapshots.set(element, snapshot);
212
+ return snapshot;
213
+ };
214
+
215
+ const runPendingUpdate = (element, pendingUpdate) => {
216
+ if (pendingUpdates.get(element) !== pendingUpdate) return;
217
+
218
+ pendingUpdates.delete(element);
219
+
220
+ // Prop values remain live references between the parent render and this
221
+ // frame. Refresh the latest snapshot so the stored baseline matches the
222
+ // value the child is about to render.
223
+ const nextSnapshot = createPropsSnapshot(pendingUpdate.newProps);
224
+ propsSnapshots.set(element, nextSnapshot);
225
+ const changedPropKeys = getChangedPropKeys(
226
+ pendingUpdate.previousSnapshot,
227
+ nextSnapshot,
228
+ );
229
+
230
+ if (changedPropKeys.length === 0) {
231
+ element.removeAttribute("isDirty");
232
+ return;
233
+ }
234
+
235
+ const previousProps = createOldProps(pendingUpdate.previousSnapshot);
236
+
237
+ element.render();
238
+ element.removeAttribute("isDirty");
239
+
240
+ if (element.handlers && element.handlers.handleOnUpdate) {
241
+ const deps = {
242
+ ...element.deps,
243
+ store: element.store,
244
+ render: element.render.bind(element),
245
+ handlers: element.handlers,
246
+ dispatchEvent: element.dispatchEvent.bind(element),
247
+ refs: element.refIds || {},
248
+ };
249
+ element.handlers.handleOnUpdate(deps, {
250
+ oldProps: previousProps,
251
+ newProps: pendingUpdate.newProps,
252
+ });
253
+ }
254
+ };
255
+
3
256
  export const createWebComponentUpdateHook = ({
4
257
  scheduleFrameFn = scheduleFrame,
5
258
  } = {}) => {
6
259
  return {
260
+ insert: (vnode) => {
261
+ const element = vnode.elm;
262
+ if (!isRettangoliComponent(element)) return;
263
+ storePropsSnapshot(element, vnode.data?.props || {});
264
+ },
7
265
  update: (oldVnode, vnode) => {
266
+ const element = vnode.elm;
267
+ if (
268
+ !isRettangoliComponent(element) ||
269
+ typeof element.render !== "function"
270
+ ) {
271
+ return;
272
+ }
273
+
8
274
  const oldProps = oldVnode.data?.props || {};
9
275
  const newProps = vnode.data?.props || {};
276
+ const pendingUpdate = pendingUpdates.get(element);
10
277
 
11
- const propsChanged = JSON.stringify(oldProps) !== JSON.stringify(newProps);
12
- if (!propsChanged) {
278
+ if (pendingUpdate) {
279
+ pendingUpdate.newProps = newProps;
13
280
  return;
14
281
  }
15
282
 
16
- const element = vnode.elm;
17
- if (!element || typeof element.render !== "function") {
283
+ const previousSnapshot =
284
+ propsSnapshots.get(element) || createPropsSnapshot(oldProps);
285
+ const nextSnapshot = createPropsSnapshot(newProps);
286
+ const changedPropKeys = getChangedPropKeys(
287
+ previousSnapshot,
288
+ nextSnapshot,
289
+ );
290
+ if (changedPropKeys.length === 0) {
291
+ propsSnapshots.set(element, nextSnapshot);
18
292
  return;
19
293
  }
20
294
 
295
+ const nextPendingUpdate = {
296
+ previousSnapshot,
297
+ newProps,
298
+ };
299
+ pendingUpdates.set(element, nextPendingUpdate);
300
+
21
301
  element.setAttribute("isDirty", "true");
22
302
  scheduleFrameFn(() => {
23
- element.render();
24
- element.removeAttribute("isDirty");
25
-
26
- if (element.handlers && element.handlers.handleOnUpdate) {
27
- const deps = {
28
- ...(element.deps),
29
- store: element.store,
30
- render: element.render.bind(element),
31
- handlers: element.handlers,
32
- dispatchEvent: element.dispatchEvent.bind(element),
33
- refs: element.refIds || {},
34
- };
35
- element.handlers.handleOnUpdate(deps, {
36
- oldProps,
37
- newProps,
38
- });
39
- }
303
+ runPendingUpdate(element, nextPendingUpdate);
40
304
  });
41
305
  },
42
306
  };
@@ -15,7 +15,10 @@ import {
15
15
  buildObservedAttributes,
16
16
  } from "../core/runtime/componentRuntime.js";
17
17
  import { initializeComponentDom } from "./componentDom.js";
18
- import { createWebComponentUpdateHook } from "./componentUpdateHook.js";
18
+ import {
19
+ createWebComponentUpdateHook,
20
+ RETTANGOLI_COMPONENT_MARKER,
21
+ } from "./componentUpdateHook.js";
19
22
  import { scheduleFrame } from "./scheduler.js";
20
23
 
21
24
  export const createWebComponentClass = ({
@@ -34,6 +37,7 @@ export const createWebComponentClass = ({
34
37
  deps,
35
38
  }) => {
36
39
  class BaseComponent extends HTMLElement {
40
+ [RETTANGOLI_COMPONENT_MARKER] = true;
37
41
  elementName;
38
42
  styles;
39
43
  _snabbdomH;