@praxisjs/runtime 0.7.0 → 0.8.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.
Files changed (138) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/children.d.ts.map +1 -1
  3. package/dist/children.js +2 -1
  4. package/dist/children.js.map +1 -1
  5. package/dist/component.d.ts +2 -3
  6. package/dist/component.d.ts.map +1 -1
  7. package/dist/component.js +133 -60
  8. package/dist/component.js.map +1 -1
  9. package/dist/context.d.ts +13 -0
  10. package/dist/context.d.ts.map +1 -1
  11. package/dist/context.js +24 -2
  12. package/dist/context.js.map +1 -1
  13. package/dist/dom/constants.d.ts +1 -1
  14. package/dist/dom/constants.d.ts.map +1 -1
  15. package/dist/dom/constants.js +24 -83
  16. package/dist/dom/constants.js.map +1 -1
  17. package/dist/dom/create.d.ts +1 -1
  18. package/dist/dom/create.d.ts.map +1 -1
  19. package/dist/dom/create.js +3 -3
  20. package/dist/dom/create.js.map +1 -1
  21. package/dist/dom/events.d.ts +29 -1
  22. package/dist/dom/events.d.ts.map +1 -1
  23. package/dist/dom/events.js +167 -17
  24. package/dist/dom/events.js.map +1 -1
  25. package/dist/dom/props.d.ts +4 -0
  26. package/dist/dom/props.d.ts.map +1 -1
  27. package/dist/dom/props.js +57 -8
  28. package/dist/dom/props.js.map +1 -1
  29. package/dist/dom/range.d.ts +4 -0
  30. package/dist/dom/range.d.ts.map +1 -0
  31. package/dist/dom/range.js +43 -0
  32. package/dist/dom/range.js.map +1 -0
  33. package/dist/dom/remove.d.ts.map +1 -1
  34. package/dist/dom/remove.js +2 -8
  35. package/dist/dom/remove.js.map +1 -1
  36. package/dist/element.d.ts.map +1 -1
  37. package/dist/element.js +7 -5
  38. package/dist/element.js.map +1 -1
  39. package/dist/hmr.d.ts +35 -0
  40. package/dist/hmr.d.ts.map +1 -0
  41. package/dist/hmr.js +85 -0
  42. package/dist/hmr.js.map +1 -0
  43. package/dist/hydration-context.d.ts +9 -1
  44. package/dist/hydration-context.d.ts.map +1 -1
  45. package/dist/hydration-context.js +34 -13
  46. package/dist/hydration-context.js.map +1 -1
  47. package/dist/hydration.d.ts.map +1 -1
  48. package/dist/hydration.js +4 -11
  49. package/dist/hydration.js.map +1 -1
  50. package/dist/index.d.ts +16 -5
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +31 -7
  53. package/dist/index.js.map +1 -1
  54. package/dist/list.d.ts +72 -0
  55. package/dist/list.d.ts.map +1 -0
  56. package/dist/list.js +786 -0
  57. package/dist/list.js.map +1 -0
  58. package/dist/portal.d.ts.map +1 -1
  59. package/dist/portal.js +3 -0
  60. package/dist/portal.js.map +1 -1
  61. package/dist/reactive.d.ts +2 -2
  62. package/dist/reactive.d.ts.map +1 -1
  63. package/dist/reactive.js +109 -34
  64. package/dist/reactive.js.map +1 -1
  65. package/dist/scope.d.ts +14 -4
  66. package/dist/scope.d.ts.map +1 -1
  67. package/dist/scope.js +55 -15
  68. package/dist/scope.js.map +1 -1
  69. package/dist/template.d.ts +48 -0
  70. package/dist/template.d.ts.map +1 -0
  71. package/dist/template.js +270 -0
  72. package/dist/template.js.map +1 -0
  73. package/package.json +15 -6
  74. package/src/children.ts +2 -1
  75. package/src/component.ts +135 -63
  76. package/src/context.ts +35 -4
  77. package/src/dom/constants.ts +25 -82
  78. package/src/dom/create.ts +3 -3
  79. package/src/dom/events.ts +188 -17
  80. package/src/dom/props.ts +66 -9
  81. package/src/dom/range.ts +45 -0
  82. package/src/dom/remove.ts +3 -7
  83. package/src/element.ts +8 -6
  84. package/src/hmr.ts +110 -0
  85. package/src/hydration-context.ts +34 -13
  86. package/src/hydration.ts +4 -9
  87. package/src/index.ts +54 -7
  88. package/src/list.ts +886 -0
  89. package/src/portal.ts +3 -0
  90. package/src/reactive.ts +147 -40
  91. package/src/scope.ts +63 -16
  92. package/src/template.ts +271 -0
  93. package/dist/__tests__/children.test.d.ts +0 -2
  94. package/dist/__tests__/children.test.d.ts.map +0 -1
  95. package/dist/__tests__/children.test.js +0 -241
  96. package/dist/__tests__/children.test.js.map +0 -1
  97. package/dist/__tests__/component.test.d.ts +0 -2
  98. package/dist/__tests__/component.test.d.ts.map +0 -1
  99. package/dist/__tests__/component.test.js +0 -331
  100. package/dist/__tests__/component.test.js.map +0 -1
  101. package/dist/__tests__/context.test.d.ts +0 -2
  102. package/dist/__tests__/context.test.d.ts.map +0 -1
  103. package/dist/__tests__/context.test.js +0 -111
  104. package/dist/__tests__/context.test.js.map +0 -1
  105. package/dist/__tests__/dom.test.d.ts +0 -2
  106. package/dist/__tests__/dom.test.d.ts.map +0 -1
  107. package/dist/__tests__/dom.test.js +0 -385
  108. package/dist/__tests__/dom.test.js.map +0 -1
  109. package/dist/__tests__/hydration.test.d.ts +0 -2
  110. package/dist/__tests__/hydration.test.d.ts.map +0 -1
  111. package/dist/__tests__/hydration.test.js +0 -299
  112. package/dist/__tests__/hydration.test.js.map +0 -1
  113. package/dist/__tests__/portal.test.d.ts +0 -2
  114. package/dist/__tests__/portal.test.d.ts.map +0 -1
  115. package/dist/__tests__/portal.test.js +0 -157
  116. package/dist/__tests__/portal.test.js.map +0 -1
  117. package/dist/__tests__/remove.test.d.ts +0 -2
  118. package/dist/__tests__/remove.test.d.ts.map +0 -1
  119. package/dist/__tests__/remove.test.js +0 -45
  120. package/dist/__tests__/remove.test.js.map +0 -1
  121. package/dist/__tests__/render.test.d.ts +0 -2
  122. package/dist/__tests__/render.test.d.ts.map +0 -1
  123. package/dist/__tests__/render.test.js +0 -97
  124. package/dist/__tests__/render.test.js.map +0 -1
  125. package/dist/__tests__/scope.test.d.ts +0 -2
  126. package/dist/__tests__/scope.test.d.ts.map +0 -1
  127. package/dist/__tests__/scope.test.js +0 -110
  128. package/dist/__tests__/scope.test.js.map +0 -1
  129. package/src/__tests__/children.test.ts +0 -276
  130. package/src/__tests__/component.test.ts +0 -363
  131. package/src/__tests__/context.test.ts +0 -128
  132. package/src/__tests__/dom.test.ts +0 -434
  133. package/src/__tests__/hydration.test.ts +0 -360
  134. package/src/__tests__/portal.test.ts +0 -177
  135. package/src/__tests__/remove.test.ts +0 -51
  136. package/src/__tests__/render.test.ts +0 -119
  137. package/src/__tests__/scope.test.ts +0 -120
  138. package/tsconfig.json +0 -8
package/src/hmr.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { isComponent, type ComponentConstructor } from "@praxisjs/shared/internal";
2
+
3
+ import type { Scope } from "./scope";
4
+
5
+ // Hot module replacement of components, driven by the code @praxisjs/vite-plugin appends to each
6
+ // module in development: every top-level component class is registered under a stable id (the
7
+ // file and the class name). When an edited module runs again, its new classes replace the old ones
8
+ // under the same ids, and every mounted instance of an old class is rendered again with the new
9
+ // one, in place. Nothing of this runs until a class is registered, so production builds only pay
10
+ // the `hotEnabled` checks.
11
+
12
+ /** A mounted instance of a registered component: what it needs to be mounted again in place. */
13
+ export interface HotInstance {
14
+ ctor: ComponentConstructor;
15
+ props: Record<string, unknown>;
16
+ parentScope: Scope;
17
+ scope: Scope;
18
+ start: Comment;
19
+ end: Comment;
20
+ }
21
+
22
+ interface HotRecord {
23
+ current: ComponentConstructor;
24
+ instances: Set<HotInstance>;
25
+ }
26
+
27
+ /** True once a component class was registered: mountComponent then tracks hot instances. */
28
+ export let hotEnabled = false;
29
+
30
+ // A WeakMap, not a static property: a subclass would inherit a property and resolve to its base.
31
+ const ids = new WeakMap<ComponentConstructor, string>();
32
+ const records = new Map<string, HotRecord>();
33
+ const stale = new Set<HotRecord>();
34
+
35
+ let remount: ((hot: HotInstance, ctor: ComponentConstructor) => void) | null = null;
36
+
37
+ /** Set by the component module, which owns mounting (kept out of here to avoid an import cycle). */
38
+ export function setHotRemount(fn: (hot: HotInstance, ctor: ComponentConstructor) => void): void {
39
+ remount = fn;
40
+ }
41
+
42
+ /** The newest class registered under the id of `ctor`, or `ctor` itself. */
43
+ export function latestClass(ctor: ComponentConstructor): ComponentConstructor {
44
+ const id = ids.get(ctor);
45
+ return id === undefined ? ctor : (records.get(id) as HotRecord).current;
46
+ }
47
+
48
+ export function isHotClass(ctor: ComponentConstructor): boolean {
49
+ return ids.has(ctor);
50
+ }
51
+
52
+ /** Records a mounted instance of a registered class until its scope is disposed. */
53
+ export function trackHotInstance(hot: HotInstance): void {
54
+ const record = records.get(ids.get(hot.ctor) as string) as HotRecord;
55
+ record.instances.add(hot);
56
+ hot.scope.add(() => { record.instances.delete(hot); });
57
+ }
58
+
59
+ function flush(): void {
60
+ const records = [...stale];
61
+ stale.clear();
62
+ let error: unknown;
63
+ let failed = false;
64
+ for (const record of records) {
65
+ for (const hot of [...record.instances]) {
66
+ // Gone with a parent remounted before it, or already mounted with the new class.
67
+ if (!record.instances.has(hot) || hot.ctor === record.current) continue;
68
+ try {
69
+ (remount as (hot: HotInstance, ctor: ComponentConstructor) => void)(hot, record.current);
70
+ } catch (e) {
71
+ if (!failed) error = e;
72
+ failed = true;
73
+ }
74
+ }
75
+ }
76
+ if (failed) throw error;
77
+ }
78
+
79
+ /**
80
+ * Registers `ctor` under `id`. Called by the code the Vite plugin appends to each module in
81
+ * development, every time the module runs. Mounted instances of a class registered before under
82
+ * the same id are mounted again with `ctor`, in one microtask for every class of the update, so a
83
+ * parent and its children edited together render once. Returns false for a class that is not a
84
+ * component, which is not registered.
85
+ */
86
+ export function registerHot(ctor: unknown, id: string): boolean {
87
+ if (!isComponent(ctor)) return false;
88
+ hotEnabled = true;
89
+ ids.set(ctor, id);
90
+ const record = records.get(id);
91
+ if (record === undefined) {
92
+ records.set(id, { current: ctor, instances: new Set() });
93
+ } else if (record.current !== ctor) {
94
+ record.current = ctor;
95
+ if (stale.size === 0) queueMicrotask(flush);
96
+ stale.add(record);
97
+ }
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * Whether an updated module can replace its previous version in place: every export is a
103
+ * registered component class. Anything else (a helper, a constant, a store) may be held by the
104
+ * modules that import it, so the update has to reach them (Vite then re-runs or reloads them).
105
+ */
106
+ export function acceptsHotUpdate(module: Record<string, unknown> | undefined): boolean {
107
+ if (module === undefined) return false;
108
+ const values = Object.values(module);
109
+ return values.length > 0 && values.every((value) => typeof value === "function" && ids.has(value as ComponentConstructor));
110
+ }
@@ -1,6 +1,11 @@
1
1
  import type { Scope } from "./scope";
2
2
 
3
- let _recording = false;
3
+ /**
4
+ * True during a hydration build pass (see runInRecording). Exported as a live binding so the hot
5
+ * render paths read a variable instead of calling isRecording() for every binding.
6
+ */
7
+
8
+ export let recording = false;
4
9
 
5
10
  /**
6
11
  * Marks the synchronous "build" pass of a hydration attempt: `factory()` runs
@@ -11,17 +16,17 @@ let _recording = false;
11
16
  * element instead of the fresh one. No-op / zero cost outside a build pass.
12
17
  */
13
18
  export function runInRecording<T>(fn: () => T): T {
14
- const prev = _recording;
15
- _recording = true;
19
+ const prev = recording;
20
+ recording = true;
16
21
  try {
17
22
  return fn();
18
23
  } finally {
19
- _recording = prev;
24
+ recording = prev;
20
25
  }
21
26
  }
22
27
 
23
28
  export function isRecording(): boolean {
24
- return _recording;
29
+ return recording;
25
30
  }
26
31
 
27
32
  interface PropRecord {
@@ -35,19 +40,35 @@ export function recordProps(el: Element, props: Record<string, unknown>, scope:
35
40
  _propRecords.set(el, { props, scope });
36
41
  }
37
42
 
43
+ /** Adds one prop to an element's record, for elements whose props are applied one at a time. */
44
+ export function recordProp(el: Element, key: string, value: unknown, scope: Scope): void {
45
+ const record = _propRecords.get(el);
46
+ if (record === undefined) _propRecords.set(el, { props: { [key]: value }, scope });
47
+ else record.props[key] = value;
48
+ }
49
+
38
50
  export function getRecordedProps(el: Element): PropRecord | undefined {
39
51
  return _propRecords.get(el);
40
52
  }
41
53
 
42
- // mountReactive() keeps a `currentNodes` array (by reference) for diffing its
43
- // *next* update. If reconcile() replaces one of those fresh nodes with a real
44
- // one, it needs to overwrite that array in place so the closure sees the swap.
45
- const _reactiveOwners = new WeakMap<Node, Node[]>();
54
+ // Code that keeps references to fresh nodes for its *next* update (mountReactive's
55
+ // `currentNodes`, a list row's boundaries) registers how to repoint them, so reconcile()
56
+ // can tell it when it keeps a real element in place of a fresh one. One node can have several
57
+ // owners (a list row's element is also part of the nodes an enclosing reactive child returned).
58
+ const _replacers = new WeakMap<Node, (real: Node) => void>();
46
59
 
47
- export function registerReactiveNode(node: Node, owner: Node[]): void {
48
- _reactiveOwners.set(node, owner);
60
+ export function onHydrationReplace(node: Node, replace: (real: Node) => void): void {
61
+ const prev = _replacers.get(node);
62
+ _replacers.set(node, prev === undefined ? replace : (real) => { prev(real); replace(real); });
63
+ }
64
+
65
+ export function getHydrationReplacer(node: Node): ((real: Node) => void) | undefined {
66
+ return _replacers.get(node);
49
67
  }
50
68
 
51
- export function getReactiveOwner(node: Node): Node[] | undefined {
52
- return _reactiveOwners.get(node);
69
+ export function registerReactiveNode(node: Node, owner: Node[]): void {
70
+ onHydrationReplace(node, (real) => {
71
+ const idx = owner.indexOf(node);
72
+ if (idx !== -1) owner[idx] = real;
73
+ });
53
74
  }
package/src/hydration.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { applyProp } from "./dom/props";
2
- import { getReactiveOwner, getRecordedProps } from "./hydration-context";
2
+ import { getHydrationReplacer, getRecordedProps } from "./hydration-context";
3
3
 
4
4
  function replayProps(real: Element, fresh: Element): void {
5
5
  const record = getRecordedProps(fresh);
@@ -10,15 +10,10 @@ function replayProps(real: Element, fresh: Element): void {
10
10
  }
11
11
  }
12
12
 
13
- // mountReactive()'s `currentNodes` array holds fresh node references for its
14
- // *next* diff. If we're keeping `real` instead of `fresh` here, that array
15
- // needs to see the swap too — it's registered by reference, so this mutation
16
- // is visible to whichever effect closure owns it.
13
+ // mountReactive()'s `currentNodes` and list rows keep fresh node references for their
14
+ // *next* update. If we're keeping `real` instead of `fresh` here, they need to see the swap.
17
15
  function notifyReplacement(fresh: Node, real: Node): void {
18
- const owner = getReactiveOwner(fresh);
19
- if (!owner) return;
20
- const idx = owner.indexOf(fresh);
21
- if (idx !== -1) owner[idx] = real;
16
+ getHydrationReplacer(fresh)?.(real);
22
17
  }
23
18
 
24
19
  /**
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { mountChildren } from "./children";
2
2
  import { runInScope } from "./context";
3
+ import { addEventRoot } from "./dom/events";
3
4
  import { reconcile } from "./hydration";
4
5
  import { runInRecording } from "./hydration-context";
5
6
  import { Scope } from "./scope";
7
+ import { syncTemplateDocument } from "./template";
6
8
 
7
9
  /** Written by `@praxisjs/ssg` onto the mount container in its prerendered HTML. */
8
10
  const SSG_MARKER = "data-praxis-ssg";
@@ -11,8 +13,10 @@ function mountCreate(
11
13
  factory: () => Node | Node[] | null,
12
14
  container: HTMLElement,
13
15
  ): () => void {
16
+ syncTemplateDocument();
14
17
  const rootScope = new Scope();
15
18
  container.innerHTML = "";
19
+ const removeEventRoot = addEventRoot(container);
16
20
 
17
21
  runInScope(rootScope, () => {
18
22
  const result = factory();
@@ -21,6 +25,7 @@ function mountCreate(
21
25
 
22
26
  return () => {
23
27
  rootScope.dispose();
28
+ removeEventRoot();
24
29
  container.innerHTML = "";
25
30
  };
26
31
  }
@@ -29,6 +34,7 @@ function hydrate(
29
34
  factory: () => Node | Node[] | null,
30
35
  container: HTMLElement,
31
36
  ): () => void {
37
+ syncTemplateDocument();
32
38
  const rootScope = new Scope();
33
39
  const scratch = document.createDocumentFragment();
34
40
 
@@ -44,24 +50,40 @@ function hydrate(
44
50
  });
45
51
 
46
52
  reconcile(container, Array.from(scratch.childNodes));
53
+ const removeEventRoot = addEventRoot(container);
47
54
 
48
55
  return () => {
49
56
  rootScope.dispose();
57
+ removeEventRoot();
50
58
  container.innerHTML = "";
51
59
  };
52
60
  }
53
61
 
62
+ // Reached only through enableHydration(), so an app that never calls it leaves the reconciliation
63
+ // and every recording branch of the render paths out of its bundle.
64
+ let hydrateRoot: typeof hydrate | null = null;
65
+
66
+ /**
67
+ * Lets render() hydrate the HTML `@praxisjs/ssg` prerendered. `ssgPlugin()` calls it at the start of
68
+ * the client bundle, so apps it builds need nothing more; a page prerendered another way calls it
69
+ * once before render(). Without it, a container carrying the hydration marker is cleared and
70
+ * mounted fresh.
71
+ */
72
+ export function enableHydration(): void {
73
+ hydrateRoot = hydrate;
74
+ }
75
+
54
76
  /**
55
77
  * Renders a component tree into a container element.
56
78
  *
57
79
  * The factory function is called once inside the root scope,
58
80
  * so `jsx()` can access the current scope via `getCurrentScope()`.
59
81
  *
60
- * If `container` carries the marker `@praxisjs/ssg` stamps onto prerendered
61
- * HTML, the tree is built normally into a detached scratch node and then
62
- * reconciled against the existing DOM (real elements are kept and have their
63
- * props/listeners replayed onto them; text/comments and any mismatched nodes
64
- * are recreated) instead of clearing the container and mounting fresh.
82
+ * Once hydration is enabled (see `enableHydration()`), a `container` carrying the marker
83
+ * `@praxisjs/ssg` stamps onto prerendered HTML has its tree built normally into a detached
84
+ * scratch node and then reconciled against the existing DOM (real elements are kept and have their
85
+ * props/listeners replayed onto them; text/comments and any mismatched nodes are recreated)
86
+ * instead of being cleared and mounted fresh.
65
87
  *
66
88
  * @example
67
89
  * render(() => <App />, document.getElementById('app'));
@@ -70,8 +92,8 @@ export function render(
70
92
  factory: () => Node | Node[] | null,
71
93
  container: HTMLElement,
72
94
  ): () => void {
73
- if (container.hasAttribute(SSG_MARKER)) {
74
- return hydrate(factory, container);
95
+ if (hydrateRoot !== null && container.hasAttribute(SSG_MARKER)) {
96
+ return hydrateRoot(factory, container);
75
97
  }
76
98
  return mountCreate(factory, container);
77
99
  }
@@ -79,5 +101,30 @@ export function render(
79
101
  export { Scope } from "./scope";
80
102
  export { runInScope, getCurrentScope } from "./context";
81
103
  export { mountElement } from "./element";
104
+ export { mountChildren } from "./children";
105
+ export {
106
+ template as _$template,
107
+ html as _$html,
108
+ element as _$element,
109
+ prop as _$prop,
110
+ reactive as _$reactive,
111
+ insert as _$insert,
112
+ reactiveOnly as _$reactiveOnly,
113
+ insertOnly as _$insertOnly,
114
+ on as _$on,
115
+ event as _$event,
116
+ onEvent as _$onEvent,
117
+ className as _$className,
118
+ scope as _$scope,
119
+ } from "./template";
82
120
  export { mountComponent } from "./component";
121
+ export { registerHot as _$hmrRegister, acceptsHotUpdate as _$hmrAccepts } from "./hmr";
83
122
  export { Portal, type PortalProps } from "./portal";
123
+ export {
124
+ For,
125
+ Index,
126
+ type ForProps,
127
+ type IndexProps,
128
+ type ListSource,
129
+ type ListTransition,
130
+ } from "./list";