@octanejs/testing-library 0.1.50 → 0.1.52

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 (3) hide show
  1. package/README.md +15 -0
  2. package/package.json +7 -5
  3. package/src/pure.ts +46 -25
package/README.md CHANGED
@@ -10,6 +10,9 @@ npm install @octanejs/testing-library
10
10
  pnpm add @octanejs/testing-library
11
11
  ```
12
12
 
13
+ This package peers on **Octane `^0.2.5`**. Published 0.2.4 and earlier do not export
14
+ `isInActScope`, which both public entries import.
15
+
13
16
  The split mirrors RTL's own architecture (and
14
17
  `docs/react-library-compat-plan.md` §2): **`@testing-library/dom` is
15
18
  framework-agnostic and reused verbatim** — every query, `screen`, `within`,
@@ -44,6 +47,11 @@ test('increments', () => {
44
47
  - `screen`, `waitFor`, `within`, … — dom-testing-library, re-exported
45
48
  - `fireEvent` — DOM event helpers, with focus/blur pairs for delegated handlers
46
49
 
50
+ Multiple `rerender` calls inside one `act` callback commit together when that
51
+ callback finishes. Await `act` before asserting results that depend on promises
52
+ or effects. Promise work settles even when timeout clocks are frozen; advance
53
+ fake timers explicitly when the behavior itself depends on a timer.
54
+
47
55
  ## Octane-specific surface
48
56
 
49
57
  **Components are values in plain-`.ts` tests** — there's no JSX in a `.ts`
@@ -155,3 +163,10 @@ text commit on `tab()`, checkbox click ordering, `keyboard()`).
155
163
  Current scope, known divergences, and verification status are tracked in the
156
164
  generated [bindings status table](../../docs/bindings-status.md), sourced from
157
165
  this package's [`status.json`](./status.json).
166
+
167
+ ## Runtime requirement
168
+
169
+ This release requires Octane 0.2.5 or later in the 0.2 line. Upgrade Octane together
170
+ with testing-library so the `isInActScope` API used for nested `act` batching is
171
+ available. The packed-consumer checks exercise that API and rendering inside
172
+ an outer `act` scope.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/testing-library",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -41,14 +41,16 @@
41
41
  "@testing-library/dom": "^10.4.1"
42
42
  },
43
43
  "peerDependencies": {
44
- "octane": "^0.1.51 || ^0.2.0"
44
+ "octane": "^0.2.5 || ^0.3.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@testing-library/user-event": "^14.6.1",
48
- "react": "^19.2.7",
49
- "react-dom": "^19.2.7",
48
+ "esbuild": "^0.28.1",
49
+ "react": "19.2.7",
50
+ "react-dom": "19.2.7",
51
+ "semver": "7.8.5",
50
52
  "vitest": "^4.1.10",
51
- "octane": "0.2.3"
53
+ "octane": "0.3.0"
52
54
  },
53
55
  "scripts": {
54
56
  "test": "vitest run"
package/src/pure.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import {
24
24
  act,
25
+ isInActScope,
25
26
  createElement,
26
27
  createRoot,
27
28
  drainPassiveEffects,
@@ -33,6 +34,7 @@ import {
33
34
  withSlot,
34
35
  type ComponentBody,
35
36
  type ElementDescriptor,
37
+ type OctaneNode,
36
38
  type Root,
37
39
  } from 'octane';
38
40
  import {
@@ -108,6 +110,7 @@ configureDTL({
108
110
  interface RootEntry {
109
111
  container: Element;
110
112
  root: Root;
113
+ valueRoot: boolean;
111
114
  }
112
115
  const mountedContainers = new Set<Element>();
113
116
  const mountedRootEntries: RootEntry[] = [];
@@ -116,13 +119,11 @@ const mountedRootEntries: RootEntry[] = [];
116
119
  // UI normalization — octane's two authoring forms into one mountable element.
117
120
  // ─────────────────────────────────────────────────────────────────────────────
118
121
 
119
- /** What `render`/`rerender` accept: a component body, or a `createElement` result. */
120
- export type OctaneUI<P = any> = ComponentBody<P> | ElementDescriptor<P>;
122
+ /** A component body with optional props, or any Octane renderable. */
123
+ export type OctaneUI<P = any> = ComponentBody<P> | OctaneNode;
121
124
 
122
- // A root can only mount a COMPONENT. `render(createElement('div', …))` (RTL's
123
- // `render(<div/>)`) produces a HOST descriptor, so it is returned from a pass-
124
- // through component and rendered by the runtime's value-position renderer.
125
- function ValueRoot(props: { children: unknown }): unknown {
125
+ // Arrays and host values share a stable value-rendering owner across rerenders.
126
+ function ValueRoot(props: { children: OctaneNode }): OctaneNode {
126
127
  return props.children;
127
128
  }
128
129
 
@@ -137,12 +138,12 @@ function toElement<P>(ui: OctaneUI<P>, props: P | undefined): ElementDescriptor
137
138
  return ui;
138
139
  }
139
140
  if (typeof ui !== 'function') {
140
- throw new Error(
141
- 'render/rerender expects an octane component (a function) or an element ' +
142
- `descriptor from createElement(...); received ${typeof ui}.`,
143
- );
141
+ if (props !== undefined) {
142
+ throw new Error('render/rerender only accepts the props option with a bare component.');
143
+ }
144
+ return createElement(ValueRoot, { children: ui });
144
145
  }
145
- return createElement(ui, props);
146
+ return createElement(ui as ComponentBody<P>, props);
146
147
  }
147
148
 
148
149
  function wrapUiIfNeeded(
@@ -152,8 +153,10 @@ function wrapUiIfNeeded(
152
153
  return wrapperComponent ? createElement(wrapperComponent, { children: element }) : element;
153
154
  }
154
155
 
155
- function mountable(element: ElementDescriptor): ElementDescriptor {
156
- return typeof element.type === 'string'
156
+ function mountable(element: ElementDescriptor, valueRoot: boolean): ElementDescriptor {
157
+ // Retain one value-rendering owner when rerender moves between an array,
158
+ // fragment, and single element. Keyed children keep their state across them.
159
+ return valueRoot && element.type !== ValueRoot
157
160
  ? createElement(ValueRoot, { children: element })
158
161
  : element;
159
162
  }
@@ -170,14 +173,21 @@ function mountable(element: ElementDescriptor): ElementDescriptor {
170
173
  // `waitFor`/`findBy*`/`act` are for, same as RTL.
171
174
  function settleSync(): void {
172
175
  for (let i = 0; i < 50 && hasPendingWork(); i++) {
173
- drainPassiveEffects();
174
- flushSync(() => {});
176
+ // Passive effects may schedule state updates too. They belong to this
177
+ // testing-library commit, so keep the drain inside its synchronous scope.
178
+ flushSync(() => {
179
+ drainPassiveEffects();
180
+ });
175
181
  }
176
182
  }
177
183
 
178
184
  // Commit a render synchronously — flushSync for the render itself, then the
179
185
  // cascade settle. Equivalent to RTL's `act(() => root.render(ui))` (sync form).
180
186
  function commitRender(root: Root, element: ElementDescriptor): void {
187
+ if (isInActScope()) {
188
+ root.render(element);
189
+ return;
190
+ }
181
191
  flushSync(() => {
182
192
  root.render(element);
183
193
  });
@@ -242,27 +252,32 @@ export function render<P = any>(ui: OctaneUI<P>, options: RenderOptions<P> = {})
242
252
  container = baseElement.appendChild(document.createElement('div'));
243
253
  }
244
254
 
245
- const element = mountable(wrapUiIfNeeded(toElement(ui, props), WrapperComponent));
255
+ const normalized = wrapUiIfNeeded(toElement(ui, props), WrapperComponent);
256
+ const previousEntry = mountedRootEntries.find((entry) => entry.container === container);
257
+ const valueRoot =
258
+ previousEntry?.valueRoot ??
259
+ (typeof normalized.type !== 'function' || normalized.type === ValueRoot);
260
+ const element = mountable(normalized, valueRoot);
246
261
 
247
262
  let root: Root;
248
263
  if (!mountedContainers.has(container)) {
249
264
  if (hydrate) {
250
- // hydrateRoot adopts the server DOM during creation (it renders once,
251
- // synchronously) — only the effect settle is left to do.
252
- root = hydrateRoot(container, element);
265
+ // Adoption queues insertion/layout effects. Commit those before the
266
+ // passive settle, so descendants can use callbacks installed by them.
267
+ root = flushSync(() => hydrateRoot(container, element));
253
268
  settleSync();
254
269
  } else {
255
270
  root = createRoot(container);
256
271
  commitRender(root, element);
257
272
  }
258
- mountedRootEntries.push({ container, root });
273
+ mountedRootEntries.push({ container, root, valueRoot });
259
274
  // Track the container regardless of whether WE created it, so cleanup()
260
275
  // handles caller-supplied containers too (RTL does the same).
261
276
  mountedContainers.add(container);
262
277
  } else {
263
278
  // Same container rendered again → reuse its root (same component updates
264
279
  // props in place; a different component tears down and remounts).
265
- root = mountedRootEntries.find((entry) => entry.container === container)!.root;
280
+ root = previousEntry!.root;
266
281
  commitRender(root, element);
267
282
  }
268
283
 
@@ -290,16 +305,22 @@ export function render<P = any>(ui: OctaneUI<P>, options: RenderOptions<P> = {})
290
305
  let element: ElementDescriptor;
291
306
  if (rerenderUi === undefined) {
292
307
  element = toElement(ui, props); // re-run with the original props
293
- } else if (isValidElement(rerenderUi) || typeof rerenderUi === 'function') {
294
- element = toElement(rerenderUi as OctaneUI, rerenderOptions?.props);
295
- } else {
308
+ } else if (
309
+ rerenderUi !== null &&
310
+ typeof rerenderUi === 'object' &&
311
+ !isValidElement(rerenderUi) &&
312
+ !Array.isArray(rerenderUi) &&
313
+ Object.prototype.hasOwnProperty.call(rerenderUi, 'props')
314
+ ) {
296
315
  // `{ props }` shorthand: reuse the ORIGINAL component with new props.
297
316
  const comp = isValidElement(ui) ? (ui as ElementDescriptor).type : ui;
298
317
  element = createElement(comp as any, (rerenderUi as { props?: any }).props);
318
+ } else {
319
+ element = toElement(rerenderUi, rerenderOptions?.props);
299
320
  }
300
321
  // The wrapper is re-applied so component identity is stable across
301
322
  // rerenders (same body ⇒ octane updates props in place).
302
- commitRender(root, mountable(wrapUiIfNeeded(element, WrapperComponent)));
323
+ commitRender(root, mountable(wrapUiIfNeeded(element, WrapperComponent), valueRoot));
303
324
  },
304
325
  asFragment: () => document.createRange().createContextualFragment(container.innerHTML),
305
326
  ...(getQueriesForElement(baseElement, queries as any) as BoundFunctions<typeof defaultQueries>),