ruact 0.0.14 → 0.0.16
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -1
- data/lib/generators/ruact/install/install_generator.rb +2 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +2 -2
- data/lib/ruact/component_attributes.rb +91 -0
- data/lib/ruact/configuration.rb +28 -0
- data/lib/ruact/controller/format_negotiation.rb +83 -0
- data/lib/ruact/controller/page_rendering.rb +1 -1
- data/lib/ruact/controller.rb +7 -0
- data/lib/ruact/erb_preprocessor.rb +1 -47
- data/lib/ruact/flight/js_date.rb +22 -0
- data/lib/ruact/flight/renderer.rb +8 -3
- data/lib/ruact/flight/serializer.rb +10 -4
- data/lib/ruact/flight/utf8.rb +36 -0
- data/lib/ruact/flight.rb +2 -0
- data/lib/ruact/redirect_path.rb +16 -0
- data/lib/ruact/server.rb +17 -7
- data/lib/ruact/server_functions/action_assigns.rb +52 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +1 -1
- data/lib/ruact/testing/component_query.rb +23 -1
- data/lib/ruact/testing/flight_extractor.rb +6 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +43 -5
- data/lib/ruact.rb +1 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +19 -10
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +7 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +12 -0
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +178 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +84 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +199 -9
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +19 -23
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc962c661c39c1c9905f3aee2ddb2c956dc196218f283ef5532bf8c9826fe98f
|
|
4
|
+
data.tar.gz: c3211a4a820d0ad77daebcd5ee071f91b5f10c2be9153f0bb6e62cc8153c61c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8dcd8f0f4988c3a3e0f9950e6210d68986d980256a2ff9463a2847107513ad8c3090c9949f728aaebc810ccf1937d7c52589f267dd60b5a12ae6a70f9e1ce95a
|
|
7
|
+
data.tar.gz: 4c7c1bf1221798c93d183456f07cb4d4549b8ec4bfa56ff091133edff176c77bd9709f303fcbf372ffea994530aaefcb92c069d66abe9e861578578b64f68c20
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.16] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A quoted attribute on a component was dropped without a word.** `<PostCard title="Hello" />` reached the component with no `title`: only `title={…}` was read, and the quoted form is the first thing a JSX hand writes. Component tags now take the three JSX forms. `name={ruby}` is a Ruby expression, as before — except that a hyphenated name keeps its hyphen: `aria-label={…}` used to arrive as `label`. `name="text"` is the string; a quoted value never runs Ruby. A bare `name` is `true`. Anything else raises a template error that names the attribute, including ERB inside a quoted value (`title="<%= @t %>"`), which a component attribute cannot run: write `title={@t}`.
|
|
15
|
+
|
|
16
|
+
- **The Getting Started page logged a React error.** Server-rendered siblings, such as an `<h1>` next to a component or the rows of an ERB loop, reached React as an array of elements with no keys, and React logged "Each child in a list should have a unique key" on the guide's own example. Siblings are now handed to React as separate children, the way JSX compiles them, so React does not check them for keys. That covers the page itself, any element's children and the content a `<Suspense>` boundary streams in. No key is invented, and a key the template gives still wins. A data array passed as a `children` prop is left as it is.
|
|
17
|
+
|
|
18
|
+
- **A form React handles itself also got a router request.** The client router decided on clicks and form submits before the app's own handlers ran, so a form whose `onSubmit` calls `preventDefault()` was also fetched by the router: the scaffold's form sent a stray `GET` of its own page on every save. The router now decides after them, and an event the app prevented is left alone. The same holds for a link whose `onClick` prevents the default. A handler that stops the event's propagation now also keeps it from the router, which then leaves the browser to follow the link or submit the form, as Turbo does.
|
|
19
|
+
|
|
20
|
+
- **The install message said a component works in any ERB view.** In island mode, the default, it works in the views of a controller that has `include Ruact::Controller`; the message now says so.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **A server function no longer returns what callbacks and auth libraries put on the controller, or answers 500 because of it.** A function call's JSON was the controller's whole `view_assigns`. An authentication library that memoizes the user in an instance variable (Devise's `current_user` sets `@current_user` on its first call) put that user in every response. Under `strict_serialization`, on by default in production, a user model without `ruact_props` then made every function call answer 500. Two rules now decide what the JSON holds:
|
|
25
|
+
- **What callbacks set stays out, unless the action assigns it a different object.** `@post = Post.find(...)` in the action is returned. A `before_action :set_post` whose `@post` the action only updates in place (`@post.update(...)`) is not: reassign it (`@post = @post.reload`) or `render json:`. This covers plain values too: a callback's `@current_user_id = 5` is not returned, and neither is a value the action sets to the very same object the callback stored (`@count = 0` after a callback did the same, or a memoized helper's result).
|
|
26
|
+
- **Authentication and authorization memos are never returned,** even when the action fills them by calling `current_user` or `authorize`. The names are in the new `config.server_function_hidden_ivars`, default `["current_user", "current_ability", "pundit"]` for Devise's `:user` scope, CanCanCan and Pundit. Every name starting with `_` is hidden too. Add the memo of any other scope or helper, such as Devise's `@current_admin` for `devise_for :admins`: `config.server_function_hidden_ivars += ["current_admin"]`.
|
|
27
|
+
|
|
28
|
+
A page render's view still sees every instance variable.
|
|
29
|
+
|
|
30
|
+
## [0.0.15] - 2026-10-03
|
|
31
|
+
|
|
32
|
+
### Security
|
|
33
|
+
|
|
34
|
+
- **Text in a prop could run as script on the page (stored XSS).** On a plain browser load, the page's Flight payload is inlined in a `<script>` element. It was embedded as a Ruby string literal with only a lowercase `</script>` escaped. A value containing `</SCRIPT>`, `</script >` or another casing closed the element, and the rest ran as HTML. A post titled `</SCRIPT><img src=x onerror=…>` executed its handler for everyone who opened the list. Any string a ruact page passes as a prop, such as user-written record content, could do it. The payload is now embedded as a JSON string literal with `<`, `>`, `&`, U+2028 and U+2029 escaped, so no character of it can end the element. Control characters such as `\e`, which the Ruby literal also delivered wrongly to JavaScript, now arrive intact. **Affects every version up to and including 0.0.14; upgrade.** `Ruact::Testing`'s matchers read both forms.
|
|
35
|
+
|
|
36
|
+
- **A same-origin redirect could send the browser to another site.** `redirect_to "#{root_url}#{params[:path]}"` with `path=/evil.com` gives `https://your.app//evil.com`, which is your own host, so Rails' open-redirect check accepts it. ruact then shortened every same-origin URL to its path, `//evil.com`, and a browser reads that as a URL to `evil.com`. A server function answered `{"$redirect":"//evil.com"}` and the runtime navigated there. A page redirect produced a Flight row the router refused, so the navigation silently did nothing. A path that a browser would read as another host now keeps its absolute, same-origin URL.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **Any text of 1 KB or more blanked the page.** The server sends every string of 1024 bytes or more as a separate row framed by its byte length, with no newline after it. The browser read the payload line by line, so it lost the row that holds the page and showed nothing: `[flight-client] No root row (id=0) found in payload`. A scaffolded post whose body was one long paragraph made the whole list disappear. The first load and the router now read the payload with one parser that works on bytes, so text arrives exactly as it left, including multibyte characters and newlines, wherever the network splits it. A truncated or malformed payload, or a reference to text that never arrived, is now an error that says so instead of a blank page. Long text inside Suspense content was also never sent at all; it is now, before the row that uses it. `Ruact::Testing`'s matchers compare a long prop by its text.
|
|
41
|
+
|
|
42
|
+
- **Dates and large integers reached React as marker strings.** A Ruby `Time`, `DateTime` or `ActiveSupport::TimeWithZone` prop arrived as `"$D2026-09-08T12:30:45.123Z"`, and an integer beyond JavaScript's safe range (±(2^53 − 1)) as `"$n9007199254740993"`. A component had to know ruact's transport to use them. They now arrive as a `Date`, with the same instant and millisecond precision, and as a `BigInt`; integers inside the safe range stay numbers. This holds on the first load and after a navigation. A `BigInt` is not a `number`: `JSON.stringify` refuses it and some libraries expect numbers, so format it where you use it (`String(id)`). A `Date` is not text, so `{createdAt}` as a child now raises "Objects are not valid as a React child"; format it (`createdAt.toLocaleDateString()`). Both can go back: a server function sends a `BigInt` argument as its decimal string, and `useQuery` takes a `BigInt` or `Date` param (the generated query accessors' TypeScript parameter types do not list them yet). Years outside 0–9999 are written in the form JavaScript reads (`+010000`). A calendar `Date` (`Date.today`) arrives as `"YYYY-MM-DD"`; it used to raise `Ruact::SerializationError` in production, where `strict_serialization` is on. A symbol whose name starts with `$` is escaped like a string.
|
|
43
|
+
|
|
44
|
+
- **`respond_to` answered 406 to every navigation and form submit of the client router.** The router asks for a page with `Accept: text/x-component`, which is not a Rails MIME type, so the request negotiated no format and `respond_to { |format| format.html { … } }` raised `ActionController::UnknownFormat`. That is the controller Rails' own scaffold writes: with `include Ruact::Controller`, its `create`, `update` and `destroy` failed whenever the router submitted the form. A router request now negotiates `format.html`, the same page a browser asks for: the `format.html` branch runs, and its render, status or redirect reaches the browser as a ruact answer. A URL that names a format (`/posts/1.json`) and an `Accept: application/json` request still get `format.json`. The format is negotiated before every callback, so an authentication `before_action` that answers with `respond_to` gets it too. A `respond_to` with no `format.html` still answers 406, now with a message naming the missing branch and the `data-ruact="false"` way out. A partial that exists only in another format (`.text.erb`) is no longer found by a ruact page during a router request.
|
|
45
|
+
|
|
46
|
+
- **`bin/rails server` without Vite showed an empty page and no error.** Once Vite has run once, the component manifest is on disk, so a page renders without the dev server, but its JavaScript is not served: the page answered 200 with nothing in it, and the only clue was a 404 in the browser's network tab. In development, when the Vite dev server is not running and there is no build to serve, the page now shows a banner that says so and to start the app with `bin/dev`, and the Rails log gets the same `[ruact]` line. The banner is plain HTML, so it does not need the JavaScript that failed to load. Nothing changes with Vite running, with a build present, or outside development.
|
|
47
|
+
|
|
48
|
+
- **Text that is not valid UTF-8 broke the page.** A string holding Latin-1 bytes, or bytes that are not text at all, made the page answer 500 for every viewer, because JSON refuses invalid UTF-8. A long one, 1024 bytes or more, was sent with a byte count that did not match what the browser decoded. Strings now leave as valid UTF-8: text in another encoding is transcoded, binary-tagged UTF-8 (`File.binread`, an HTTP body) is read as the text it is, and bytes that are not text become U+FFFD, as a browser shows them.
|
|
49
|
+
|
|
10
50
|
## [0.0.14] - 2026-10-02
|
|
11
51
|
|
|
12
52
|
### Fixed
|
|
@@ -104,7 +144,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
104
144
|
|
|
105
145
|
|
|
106
146
|
- **The README no longer claims props are an allowlist.** It said *"Props are an allowlist — other columns never cross"*, describing the opt-in rather than the default. The default is `as_json`: every attribute of a model prop crosses to the client, and the gem says so in the log (`ALL attributes exposed to client`). A reader who took the bullet at face value would have believed the safe behaviour was already on. The bullet now leads with what actually happens, then names `ruact_props` as the thing that makes it an allowlist and `strict_serialization` as what turns the permissive path into an error in production.
|
|
147
|
+
|
|
107
148
|
- **The server-functions bullet no longer calls the whole generated module "typed".** Only queries carry an exact signature (one property per declared keyword, since 0.0.5). An action's accessor is typed to be callable and to satisfy `<form action>`, but its arguments and resolved value are `Record<string, unknown>` and `unknown` — useful, and not the autocomplete the word "typed" promises. Both are now described as what they are.
|
|
149
|
+
|
|
108
150
|
- **The mechanism is stated where the reader meets it.** "Capitalized tag means React. Lowercase stays HTML." now sits directly under the ERB/TSX pair, and the paragraph that follows explains the wire in plain terms — the view renders server-side as it always did, the result travels as a React tree in the format React uses for Server Components, the data is inlined so React renders without a fetch, and Node builds the bundle and does nothing else.
|
|
109
151
|
|
|
110
152
|
### Fixed
|
|
@@ -144,6 +186,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
144
186
|
It is referenced by absolute URL and **nothing binary is committed here** — so neither `gem install ruact` nor a clone of this repository carries a multi-megabyte file, permanently, for a picture. Two things keep it from aging into a lie: the URL belongs to the site's URL contract, and `spec/readme_demo_message_spec.rb` pins the message the recording shows against a fixture the gem itself produced. If the wording changes, that spec goes red and names the recording as the thing to redo.
|
|
145
187
|
|
|
146
188
|
- **`LICENSE.txt`** — the gemspec has declared `spec.license = "MIT"` since the first commit while the repository contained no licence file, so both GitHub and the packaged gem shipped a promise with nothing behind it.
|
|
189
|
+
|
|
147
190
|
- **`spec.description` and `metadata["documentation_uri"]` in the gemspec** — RubyGems rendered the one-line `summary` as the whole description and fell through to rubydoc.info for documentation.
|
|
148
191
|
|
|
149
192
|
### Fixed
|
|
@@ -300,6 +343,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
300
343
|
### Tooling
|
|
301
344
|
|
|
302
345
|
- **Code coverage instrumentation** (Story 6.7). Added `simplecov` and `simplecov-lcov` as development dependencies; gem CI uploads coverage from the canonical matrix cell (Ruby 3.3 × Rails 7.2) to Codecov on every push and PR with the `gem` flag. **Baseline at merge: 88.30% line (581/658), 72.25% branch (239 specs).** (Corrects the earlier 87.89% figure recorded at story-merge time, which was a typo of the SimpleCov output for 567/644 = 88.04%; the current numbers reflect the post-review state after Story 6.7 review F1 refactored `html_converter.rb#convert_element` into helpers, which added a few lines and one new spec.) Coverage is informativo (not a CI gate); see Codecov PR comments for diff coverage on individual changes. Diff coverage target per project DoD: ≥ 90% line / ≥ 80% branch on new code.
|
|
346
|
+
|
|
303
347
|
- **Spec `rails_stub.rb` fix.** The previous `$LOADED_FEATURES.any? { |f| f.end_with?("/rails.rb") }` heuristic for skipping the `LOADED_FEATURES` insertion was unreliable — unrelated gems ship files at `*/rails.rb` (e.g. SimpleCov's `simplecov/profiles/rails.rb`). Replaced with an unconditional insertion guarded only by the existing `return if defined?(Rails)` early-exit, which is the correct invariant.
|
|
304
348
|
|
|
305
349
|
### Renamed
|
|
@@ -327,6 +371,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
327
371
|
|
|
328
372
|
- Rake task descriptions and internal `require` statements migrated from `rails_rsc` to `ruact`. Public API is unchanged; this is a documentation and tooling rename only.
|
|
329
373
|
- **Render context now passed explicitly** (Story 7.1). `Ruact::ComponentRegistry` (which used `Thread.current`) has been removed; the per-render component list is now an instance of `Ruact::RenderContext` passed explicitly through `Controller#ruact_render → RenderPipeline → HtmlConverter`. The `Ruact/NoSharedState` cop now passes with no exceptions in `lib/ruact/`. **No public API change.** Note: `Ruact::Flight::*`, `Ruact::Internal::*`, and `Ruact::RenderContext` are not part of the public API and may change between minors. Hosts upgrading need no application code changes. See decision note for rationale and contributor guidance.
|
|
374
|
+
|
|
330
375
|
- **`RenderPipeline` entry points consolidated** (Story 7.2). `RenderPipeline#call`, `#stream`, and `#from_html` have been removed and replaced with a single `#render(input, mode:)` entry point. `input` selects the source — `{ erb: String, binding: Binding }` for ERB templates or `{ html: String, render_context: Ruact::RenderContext }` for pre-rendered HTML; `mode:` selects the output shape — `:string` returns a `String` (deferred chunks inlined eagerly), `:stream` returns an `Enumerator` of Flight rows (deferred chunks delay). Conflicting input keys, missing siblings, and unknown modes raise `ArgumentError` with the offending input named. **No public API change** — `Ruact::Controller#ruact_render` is unchanged. Note: `Ruact::Flight::*`, `Ruact::Internal::*`, and `Ruact::RenderPipeline` are not part of the public API and may change between minors. See decision note for rationale and contributor guidance.
|
|
331
376
|
|
|
332
377
|
_Migration for any external code that may have reached into `Ruact::RenderPipeline`:_ `pipeline.call(erb, binding)` → `pipeline.render({ erb: erb, binding: binding }, mode: :string)`; `pipeline.stream(erb, binding)` → `pipeline.render({ erb: erb, binding: binding }, mode: :stream)`; `pipeline.from_html(html, render_context: ctx)` → `pipeline.render({ html: html, render_context: ctx }, mode: :string)`; `pipeline.from_html(html, render_context: ctx, streaming: true)` → `pipeline.render({ html: html, render_context: ctx }, mode: :stream)`.
|
|
@@ -360,23 +405,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
360
405
|
### Added
|
|
361
406
|
|
|
362
407
|
- **ERB preprocessor** — PascalCase RSC component tags (`<Button />`, `<LikeButton postId={@post.id} />`) are transformed to Flight placeholders in ERB templates before Ruby evaluation.
|
|
408
|
+
|
|
363
409
|
- **`<Suspense>` support** — `<Suspense fallback="Loading...">` in ERB templates maps to React Suspense boundaries in the Flight payload.
|
|
410
|
+
|
|
364
411
|
- **React Flight wire format serializer** — Full Ruby-to-Flight protocol implementation covering: nil, booleans, integers, floats (NaN/Infinity/-0), strings (with `$` escaping), arrays, hashes, `Time`/`DateTime`, large strings (T rows), `ReactElement`, `SuspenseElement`, and `ClientReference`.
|
|
412
|
+
|
|
365
413
|
- **`Ruact::Controller` concern** — Include in `ApplicationController` to enable RSC rendering. Provides `ruact_render`, RSC request detection (`text/x-component` / `Ruact-Request: 1` header), HTML shell generation with inline `__FLIGHT_DATA`, and Flight-aware `redirect_to`.
|
|
414
|
+
|
|
366
415
|
- **Streaming mode** — When `ActionController::Live` is included, Flight rows are streamed to the client as they are produced (Suspense-aware).
|
|
416
|
+
|
|
367
417
|
- **Client component resolution** — `Ruact::ClientManifest` reads `public/react-client-manifest.json` (generated by the Vite plugin) and resolves component names to `ClientReference` objects via a dual-path resolver.
|
|
418
|
+
|
|
368
419
|
- **`Ruact::Serializable` mixin** — `ruact_props` DSL for declaring safe prop attributes on Ruby model objects.
|
|
420
|
+
|
|
369
421
|
- **Install generator** — `rails generate ruact:install` scaffolds the initializer, Vite config patch, and JavaScript entry point.
|
|
422
|
+
|
|
370
423
|
- **`ruact:doctor` Rake task** — Checks manifest presence, Vite server accessibility, controller setup, and streaming mode configuration.
|
|
424
|
+
|
|
371
425
|
- **`vite-plugin-ruact`** — Vite plugin (npm package, co-versioned) that scans `"use client"` components and emits `public/react-client-manifest.json`.
|
|
426
|
+
|
|
372
427
|
- **Client-side navigation** — JavaScript `ruact-router.js` intercepts same-origin link clicks and form submissions, fetches Flight payloads, and updates the React tree without full-page reloads.
|
|
428
|
+
|
|
373
429
|
- **Error overlay** — Development-mode React error boundary with dismissible overlay for Flight parse and rendering errors.
|
|
430
|
+
|
|
374
431
|
- **RSpec test suite** — 223 examples covering all modules: Flight serializer, ERB preprocessor, HTML converter, render pipeline, controller, client manifest, serializable, install generator, and `ruact:doctor`.
|
|
432
|
+
|
|
375
433
|
- **Memory benchmark** — `rake benchmark:memory` enforces a 120% allocation regression gate against `spec/benchmarks/baseline.json`.
|
|
434
|
+
|
|
376
435
|
- **CI matrix** — GitHub Actions: RSpec across Ruby 3.2 × 3.3 × Rails 7.0 × 7.1 × 7.2 × 8.0; RuboCop; YARD docs; memory benchmark; E2E system tests against React 19.0.0 and 19.x (Capybara + Cuprite); non-blocking React@next job with auto-issue on failure.
|
|
436
|
+
|
|
377
437
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
378
438
|
|
|
379
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
439
|
+
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.16...HEAD
|
|
440
|
+
[0.0.16]: https://github.com/luizcg/ruact/releases/tag/v0.0.16
|
|
441
|
+
[0.0.15]: https://github.com/luizcg/ruact/releases/tag/v0.0.15
|
|
380
442
|
[0.0.14]: https://github.com/luizcg/ruact/releases/tag/v0.0.14
|
|
381
443
|
[0.0.13]: https://github.com/luizcg/ruact/releases/tag/v0.0.13
|
|
382
444
|
[0.0.12]: https://github.com/luizcg/ruact/releases/tag/v0.0.12
|
|
@@ -425,7 +425,8 @@ module Ruact
|
|
|
425
425
|
|
|
426
426
|
show_shadcn_next_steps if shadcn?
|
|
427
427
|
|
|
428
|
-
say "\nThen add <MyComponent /> to
|
|
428
|
+
say "\nThen add <MyComponent /> to an ERB view of a ruact page:" unless whole_app?
|
|
429
|
+
say "\nThen add <MyComponent /> to any ERB view." if whole_app?
|
|
429
430
|
say layout_summary
|
|
430
431
|
say ""
|
|
431
432
|
say "Note: re-run this generator after updating the ruact gem to refresh"
|
|
@@ -84,8 +84,8 @@ READ IT instead of simulating the name generation. Regenerate after routes/queri
|
|
|
84
84
|
`<Suspense fallback="...">...</Suspense>` pair.
|
|
85
85
|
2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
|
|
86
86
|
evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
|
|
87
|
-
no `{...spread}`, no JSX children.
|
|
88
|
-
|
|
87
|
+
no `{...spread}`, no JSX children. A quoted value (`title="Hi"`) is a
|
|
88
|
+
plain string and a bare attribute is `true`, as in JSX.
|
|
89
89
|
3. **One action, two response shapes.** A `Ruact::Server` non-GET action
|
|
90
90
|
answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
|
|
91
91
|
when the request's `Accept` header is exactly `application/json`, which is
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# The attributes of a PascalCase component tag in ERB, read the way JSX
|
|
5
|
+
# reads them (see {ErbPreprocessor}). Returns ordered
|
|
6
|
+
# +[name, ruby_expr]+ pairs for the props Hash the template renders.
|
|
7
|
+
module ComponentAttributes
|
|
8
|
+
ATTR_NAME_RE = /\A[a-zA-Z_][\w-]*/
|
|
9
|
+
|
|
10
|
+
# Parses the attributes string of a component tag into ordered
|
|
11
|
+
# +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["title", "\"Hi\""]].
|
|
12
|
+
# The names feed the Story 13.5 contract check; the pairs render the props
|
|
13
|
+
# Hash. The three JSX attribute forms:
|
|
14
|
+
#
|
|
15
|
+
# name={ruby} — the Ruby expression (nested braces honored)
|
|
16
|
+
# name="text" — the string, as JSX passes it (single quotes too)
|
|
17
|
+
# name — +true+, as JSX passes a bare attribute
|
|
18
|
+
#
|
|
19
|
+
# Anything else raises instead of being dropped: an unbraced attribute used
|
|
20
|
+
# to vanish without a word, and it is the first thing a JSX hand writes.
|
|
21
|
+
def self.parse(attrs_string)
|
|
22
|
+
attrs = attrs_string.sub(%r{\s*/\z}, "")
|
|
23
|
+
pairs = []
|
|
24
|
+
i = 0
|
|
25
|
+
while i < attrs.length
|
|
26
|
+
if attrs[i].match?(/\s/)
|
|
27
|
+
i += 1
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
name = attrs[i..][ATTR_NAME_RE] ||
|
|
31
|
+
raise(PreprocessorError, "unexpected #{attrs[i, 20].inspect} in the component's attributes")
|
|
32
|
+
i += name.length
|
|
33
|
+
i += 1 while attrs[i]&.match?(/\s/)
|
|
34
|
+
if attrs[i] == "="
|
|
35
|
+
i += 1
|
|
36
|
+
i += 1 while attrs[i]&.match?(/\s/)
|
|
37
|
+
value, i = parse_value(attrs, i, name)
|
|
38
|
+
pairs << [name, value]
|
|
39
|
+
else
|
|
40
|
+
pairs << [name, "true"]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
pairs
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One attribute value at +attrs[i]+ → [ruby_expr, index after it].
|
|
47
|
+
def self.parse_value(attrs, pos, name)
|
|
48
|
+
case attrs[pos]
|
|
49
|
+
when "{"
|
|
50
|
+
expr = extract_braced_expr(attrs, pos + 1)
|
|
51
|
+
raise PreprocessorError, "#{name}={} is empty: put a Ruby expression in the braces" if expr.strip.empty?
|
|
52
|
+
|
|
53
|
+
[expr, pos + expr.length + 2]
|
|
54
|
+
when '"', "'"
|
|
55
|
+
close = attrs.index(attrs[pos], pos + 1)
|
|
56
|
+
text = attrs[(pos + 1)...(close || attrs.length)]
|
|
57
|
+
if text.include?("<%")
|
|
58
|
+
raise PreprocessorError, "#{name}= holds ERB, which a component attribute cannot run. " \
|
|
59
|
+
"Pass the Ruby in braces instead: #{name}={...}"
|
|
60
|
+
end
|
|
61
|
+
unless close
|
|
62
|
+
raise PreprocessorError, "unclosed quote in #{name}= (a component tag cannot contain `>`, " \
|
|
63
|
+
"even inside a quoted value: pass the text from Ruby instead)"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
[text.inspect, close + 1]
|
|
67
|
+
else
|
|
68
|
+
raise PreprocessorError, "#{name}= needs a value: #{name}={ruby} or #{name}=\"text\""
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Given a string and a start position (just after the opening '{'),
|
|
73
|
+
# returns the content up to the matching '}'.
|
|
74
|
+
def self.extract_braced_expr(str, start)
|
|
75
|
+
depth = 1
|
|
76
|
+
i = start
|
|
77
|
+
while i < str.length && depth.positive?
|
|
78
|
+
case str[i]
|
|
79
|
+
when "{" then depth += 1
|
|
80
|
+
when "}" then depth -= 1
|
|
81
|
+
end
|
|
82
|
+
i += 1
|
|
83
|
+
end
|
|
84
|
+
raise PreprocessorError, "unclosed brace in prop expression" if depth.positive?
|
|
85
|
+
|
|
86
|
+
str[start...(i - 1)]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_class_method :parse_value, :extract_braced_expr
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/ruact/configuration.rb
CHANGED
|
@@ -26,6 +26,7 @@ module Ruact
|
|
|
26
26
|
shadcn_compatible_versions
|
|
27
27
|
layout
|
|
28
28
|
layout_stylesheets
|
|
29
|
+
server_function_hidden_ivars
|
|
29
30
|
].freeze
|
|
30
31
|
|
|
31
32
|
# @!attribute [r] manifest_path
|
|
@@ -125,6 +126,19 @@ module Ruact
|
|
|
125
126
|
# @example Set an app-wide default expiry
|
|
126
127
|
# Ruact.configure { |c| c.signed_global_id_default_expires_in = 1.hour }
|
|
127
128
|
#
|
|
129
|
+
# @!attribute [r] server_function_hidden_ivars
|
|
130
|
+
# @return [Array<String>] Instance-variable names (no `@`) a server
|
|
131
|
+
# function's JSON never returns, even when the action set them: the
|
|
132
|
+
# memoization ivars of authentication and authorization libraries,
|
|
133
|
+
# which an action fills just by calling `current_user` or `authorize`.
|
|
134
|
+
# Names starting with `_` are always hidden. Default
|
|
135
|
+
# `["current_user", "current_ability", "pundit"]` (Devise's `:user`
|
|
136
|
+
# scope, CanCanCan, Pundit); another Devise scope memoizes its own
|
|
137
|
+
# `@current_<scope>` — add it. Assign to replace the list; to keep the
|
|
138
|
+
# defaults, add to them.
|
|
139
|
+
# @example Also hide an app's own memoized account
|
|
140
|
+
# Ruact.configure { |c| c.server_function_hidden_ivars += ["current_account"] }
|
|
141
|
+
#
|
|
128
142
|
# @!attribute [r] shadcn_compatible_versions
|
|
129
143
|
# @return [Array<Integer>] Story 10.5 — the shadcn/ui MAJOR versions the
|
|
130
144
|
# `ruact:scaffold` generator is regression-tested against. When the
|
|
@@ -255,6 +269,7 @@ module Ruact
|
|
|
255
269
|
@shadcn_compatible_versions = [1, 2, 4]
|
|
256
270
|
@layout = false
|
|
257
271
|
@layout_stylesheets = [:app]
|
|
272
|
+
@server_function_hidden_ivars = %w[current_user current_ability pundit]
|
|
258
273
|
end
|
|
259
274
|
end
|
|
260
275
|
|
|
@@ -278,6 +293,9 @@ module Ruact
|
|
|
278
293
|
# @api private
|
|
279
294
|
# @return [Ruact::Configuration] self, frozen
|
|
280
295
|
def seal!
|
|
296
|
+
# An Array attribute mutated in place inside the block (`list << x`)
|
|
297
|
+
# never passed through its writer; validate it here, before publication.
|
|
298
|
+
validate_attribute_value!(:server_function_hidden_ivars, server_function_hidden_ivars)
|
|
281
299
|
ATTRIBUTES.each do |attr|
|
|
282
300
|
value = public_send(attr)
|
|
283
301
|
next if value.nil? || value.frozen?
|
|
@@ -322,6 +340,7 @@ module Ruact
|
|
|
322
340
|
when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
|
|
323
341
|
when :layout then validate_layout!(value)
|
|
324
342
|
when :layout_stylesheets then validate_layout_stylesheets!(value)
|
|
343
|
+
when :server_function_hidden_ivars then validate_server_function_hidden_ivars!(value)
|
|
325
344
|
end
|
|
326
345
|
end
|
|
327
346
|
|
|
@@ -440,6 +459,15 @@ module Ruact
|
|
|
440
459
|
"major versions (e.g. [1, 2]); got #{value.inspect}."
|
|
441
460
|
end
|
|
442
461
|
|
|
462
|
+
# An Array of ivar names; a leading `@` is a typo that would hide nothing.
|
|
463
|
+
def validate_server_function_hidden_ivars!(value)
|
|
464
|
+
return if value.is_a?(Array) && value.all? { |name| name.is_a?(String) && !name.start_with?("@") }
|
|
465
|
+
|
|
466
|
+
raise Ruact::ConfigurationError,
|
|
467
|
+
"Ruact::Configuration#server_function_hidden_ivars must be an Array of instance-variable " \
|
|
468
|
+
"names without the @ (e.g. [\"current_user\"]); got #{value.inspect}."
|
|
469
|
+
end
|
|
470
|
+
|
|
443
471
|
def build_error_message(attr, location)
|
|
444
472
|
<<~MSG.strip
|
|
445
473
|
ruact: cannot mutate Ruact::Configuration##{attr} after initialization.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Controller
|
|
5
|
+
# Story 17-0j — a router request answers as `format.html`.
|
|
6
|
+
#
|
|
7
|
+
# The ruact router asks for a page with `Accept: text/x-component`, which is
|
|
8
|
+
# not a Rails MIME type: the request negotiated no format at all, and every
|
|
9
|
+
# `respond_to` raised `ActionController::UnknownFormat` (406) on a
|
|
10
|
+
# navigation or a form submit. That is the controller Rails' own scaffold
|
|
11
|
+
# writes. A router request asks for the PAGE, the same thing a browser's
|
|
12
|
+
# `text/html` asks for, so it now negotiates `:html` — unless the URL names
|
|
13
|
+
# a format (`/posts/1.json`), which wins as it always has. Whether the
|
|
14
|
+
# answer is Flight or an HTML document is still ruact's decision, read from
|
|
15
|
+
# the Accept header, not from the negotiated format.
|
|
16
|
+
module FormatNegotiation
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
# Rails' negotiated-formats slot on the request. Writing it directly,
|
|
20
|
+
# rather than `request.format =`, leaves `params[:format]` alone.
|
|
21
|
+
FORMATS_KEY = "action_dispatch.request.formats"
|
|
22
|
+
|
|
23
|
+
# `respond_to` with no `format.html` still answers 406 to a router
|
|
24
|
+
# request, as Rails does to a browser — with the reason and the fix. Only
|
|
25
|
+
# when THIS respond_to found no format: an UnknownFormat raised inside a
|
|
26
|
+
# branch (a nested respond_to, an explicit raise), or one for a format
|
|
27
|
+
# the URL named, keeps Rails' own message.
|
|
28
|
+
#
|
|
29
|
+
# @see ActionController::MimeResponds#respond_to
|
|
30
|
+
def respond_to(*mimes, &block)
|
|
31
|
+
collector = nil
|
|
32
|
+
recording = block && lambda { |given|
|
|
33
|
+
collector = given
|
|
34
|
+
yield(given)
|
|
35
|
+
}
|
|
36
|
+
super(*mimes, &recording)
|
|
37
|
+
rescue ActionController::UnknownFormat
|
|
38
|
+
raise unless __ruact_router_found_no_format?(collector, mimes)
|
|
39
|
+
|
|
40
|
+
raise ActionController::UnknownFormat,
|
|
41
|
+
"#{self.class.name}##{action_name} was asked for its page by the ruact router, which " \
|
|
42
|
+
"negotiates format.html, and its respond_to has no format.html branch. Add one, or " \
|
|
43
|
+
"link to this action with data-ruact=\"false\" so the browser loads it."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Before every callback — a host's own `prepend_before_action` included —
|
|
49
|
+
# since this sits above AbstractController::Callbacks in the chain. An
|
|
50
|
+
# auth callback that answers with `respond_to` gets the format too.
|
|
51
|
+
def process_action(...)
|
|
52
|
+
__ruact_negotiate_router_request_as_html
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def __ruact_negotiate_router_request_as_html
|
|
57
|
+
return unless ruact_request?
|
|
58
|
+
return if __ruact_url_format.present?
|
|
59
|
+
|
|
60
|
+
request.set_header(FORMATS_KEY, [Mime[:html]])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The format the URL names (`/posts/1.json`, `?format=json`). Read from
|
|
64
|
+
# the path and query only: this runs before Rails' rescue and logging,
|
|
65
|
+
# where parsing a malformed request BODY must not raise.
|
|
66
|
+
def __ruact_url_format
|
|
67
|
+
request.path_parameters[:format] || request.query_parameters[:format]
|
|
68
|
+
rescue ActionController::BadRequest, ActionDispatch::Http::Parameters::ParseError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def __ruact_router_found_no_format?(collector, mimes)
|
|
73
|
+
return false unless ruact_request? && __ruact_url_format.blank?
|
|
74
|
+
|
|
75
|
+
if collector
|
|
76
|
+
collector.format.nil?
|
|
77
|
+
else
|
|
78
|
+
request.negotiate_mime(mimes.map { |mime| Mime[mime] }).nil?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -98,7 +98,7 @@ module Ruact
|
|
|
98
98
|
# Whether Rails, left alone, would render `page` for `action`: the lookup
|
|
99
99
|
# it does itself, with the formats it negotiated for this request (and a
|
|
100
100
|
# render's own `formats:` / `variants:` / `handlers:` / `locale:`). A Flight request
|
|
101
|
-
# negotiates
|
|
101
|
+
# negotiates HTML (Ruact::Controller::FormatNegotiation) — the page; a `.json` request or a
|
|
102
102
|
# `format.json` branch negotiates JSON — the JSON template, or Rails' own
|
|
103
103
|
# MissingTemplate.
|
|
104
104
|
def __ruact_rails_would_render?(action, details, page)
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "socket"
|
|
5
5
|
require "uri"
|
|
6
|
+
require_relative "redirect_path"
|
|
6
7
|
require_relative "view_helper"
|
|
7
8
|
require_relative "validation_errors_collector"
|
|
8
9
|
require_relative "controller/document_rendering"
|
|
9
10
|
require_relative "controller/pages"
|
|
10
11
|
require_relative "controller/page_rendering"
|
|
12
|
+
require_relative "controller/format_negotiation"
|
|
11
13
|
|
|
12
14
|
module Ruact
|
|
13
15
|
# Include in the controllers whose pages render through ruact (island mode,
|
|
@@ -43,6 +45,9 @@ module Ruact
|
|
|
43
45
|
# An explicit `render` of a ruact page goes through ruact (Story 17.0i).
|
|
44
46
|
# See Ruact::Controller::PageRendering.
|
|
45
47
|
include Ruact::Controller::PageRendering
|
|
48
|
+
# A router request negotiates `format.html`, so `respond_to` answers it
|
|
49
|
+
# (Story 17-0j). See Ruact::Controller::FormatNegotiation.
|
|
50
|
+
include Ruact::Controller::FormatNegotiation
|
|
46
51
|
|
|
47
52
|
# Story 17.0f — "is this action a ruact PAGE?", answered at CLASS level so the
|
|
48
53
|
# navigation boundary (Ruact::NavigationBoundary) can ask it before any action
|
|
@@ -266,6 +271,8 @@ module Ruact
|
|
|
266
271
|
redirect_url = uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
267
272
|
redirect_url += "?#{uri.query}" if uri.query
|
|
268
273
|
redirect_url += "##{uri.fragment}" if uri.fragment
|
|
274
|
+
# Same-origin `https://app//evil.com` must not shrink to `//evil.com`.
|
|
275
|
+
redirect_url = url if Ruact::RedirectPath.protocol_relative?(redirect_url)
|
|
269
276
|
rescue ::URI::InvalidURIError
|
|
270
277
|
return super
|
|
271
278
|
end
|
|
@@ -51,11 +51,6 @@ module Ruact
|
|
|
51
51
|
# unrelated `<% "</Dialog>" %>` appears later).
|
|
52
52
|
ERB_ISLAND_RE = /<%.*?%>/m
|
|
53
53
|
|
|
54
|
-
# Matches a +{ruby_expr}+ attribute value — captures everything between the braces.
|
|
55
|
-
# We use a simple bracket-depth counter approach during scanning instead of regex
|
|
56
|
-
# because expressions can contain nested braces: {foo.bar({ a: 1 })}.
|
|
57
|
-
PROP_RE = /\b([a-zA-Z_][a-zA-Z0-9_]*)=\{/
|
|
58
|
-
|
|
59
54
|
# Transform ERB source, replacing component tags with ERB placeholders.
|
|
60
55
|
# Returns the transformed source string.
|
|
61
56
|
#
|
|
@@ -114,7 +109,7 @@ module Ruact
|
|
|
114
109
|
# the render path surfaces the clear error). In prod this is the
|
|
115
110
|
# boot-loaded Ruact.manifest, unchanged.
|
|
116
111
|
registry = ManifestResolver.resolve_soft if registry == :default
|
|
117
|
-
pairs =
|
|
112
|
+
pairs = ComponentAttributes.parse(attrs_string)
|
|
118
113
|
validate_contract(registry, component_name, pairs.map(&:first),
|
|
119
114
|
at: { file: identifier, line: line, snippet: match.strip })
|
|
120
115
|
props_ruby = pairs.map { |name, expr| "#{name.inspect} => #{expr}" }.join(", ")
|
|
@@ -247,46 +242,5 @@ module Ruact
|
|
|
247
242
|
m = identifier.to_s.match(%r{app/views/(.+)/[^/]+\z})
|
|
248
243
|
m && m[1]
|
|
249
244
|
end
|
|
250
|
-
|
|
251
|
-
# Parses the attributes string of a component tag into ordered
|
|
252
|
-
# +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["count", "5"]].
|
|
253
|
-
# Honors nested braces in values (via {#extract_braced_expr}). The names
|
|
254
|
-
# feed the Story 13.5 contract check; the pairs render the props Hash.
|
|
255
|
-
def parse_prop_pairs(attrs_string)
|
|
256
|
-
return [] if attrs_string.empty?
|
|
257
|
-
|
|
258
|
-
pairs = []
|
|
259
|
-
remaining = attrs_string.dup
|
|
260
|
-
|
|
261
|
-
while (m = PROP_RE.match(remaining))
|
|
262
|
-
prop_name = m[1]
|
|
263
|
-
# Find the matching closing brace, respecting nesting
|
|
264
|
-
value_start = m.end(0)
|
|
265
|
-
value_expr = extract_braced_expr(remaining, value_start)
|
|
266
|
-
pairs << [prop_name, value_expr]
|
|
267
|
-
# Advance past this prop
|
|
268
|
-
remaining = remaining[(value_start + value_expr.length + 1)..] # +1 for closing }
|
|
269
|
-
break if remaining.nil?
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
pairs
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
# Given a string and a start position (just after the opening '{'),
|
|
276
|
-
# returns the content up to the matching '}'.
|
|
277
|
-
def extract_braced_expr(str, start)
|
|
278
|
-
depth = 1
|
|
279
|
-
i = start
|
|
280
|
-
while i < str.length && depth.positive?
|
|
281
|
-
case str[i]
|
|
282
|
-
when "{" then depth += 1
|
|
283
|
-
when "}" then depth -= 1
|
|
284
|
-
end
|
|
285
|
-
i += 1
|
|
286
|
-
end
|
|
287
|
-
raise PreprocessorError, "unclosed brace in prop expression" if depth.positive?
|
|
288
|
-
|
|
289
|
-
str[start...(i - 1)]
|
|
290
|
-
end
|
|
291
245
|
end
|
|
292
246
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Flight
|
|
5
|
+
# The ISO 8601 form a JavaScript `Date` reads back (Story 17-0c).
|
|
6
|
+
module JsDate
|
|
7
|
+
# JavaScript parses years 0–9999 in the plain form and the others only in
|
|
8
|
+
# the expanded one (`+010000`, `-000001`); Ruby writes `10000` / `-0001`,
|
|
9
|
+
# which `new Date` turns into an Invalid Date.
|
|
10
|
+
#
|
|
11
|
+
# @param value [Time, DateTime]
|
|
12
|
+
# @return [String] ISO 8601 with milliseconds
|
|
13
|
+
def self.iso8601(value)
|
|
14
|
+
iso = value.iso8601(3)
|
|
15
|
+
year = value.year
|
|
16
|
+
return iso unless year.negative? || year > 9999
|
|
17
|
+
|
|
18
|
+
format("%<sign>s%<year>06d", sign: year.negative? ? "-" : "+", year: year.abs) + iso.sub(/\A-?\d+/, "")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -56,12 +56,17 @@ module Ruact
|
|
|
56
56
|
sleep(deferred[:delay])
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# Serialize deferred content — may produce new import rows
|
|
60
|
-
|
|
59
|
+
# Serialize deferred content — may produce new import rows, and new
|
|
60
|
+
# regular rows (a string of 1024+ bytes becomes a `T` row the deferred
|
|
61
|
+
# model row references as `$T<id>`).
|
|
62
|
+
import_count_before = @request.completed_import_chunks.length
|
|
63
|
+
regular_count_before = @request.completed_regular_chunks.length
|
|
61
64
|
deferred_value = serializer.serialize_model(deferred[:element])
|
|
62
65
|
|
|
63
|
-
# Yield
|
|
66
|
+
# Yield the rows discovered during deferred serialization BEFORE the
|
|
67
|
+
# row that references them.
|
|
64
68
|
@request.completed_import_chunks[import_count_before..].each(&block)
|
|
69
|
+
@request.completed_regular_chunks[regular_count_before..].each(&block)
|
|
65
70
|
|
|
66
71
|
yield RowEmitter.model(deferred[:id], JSON.generate(deferred_value))
|
|
67
72
|
end
|
|
@@ -33,6 +33,10 @@ module Ruact
|
|
|
33
33
|
serialize_symbol(value)
|
|
34
34
|
when Time, DateTime
|
|
35
35
|
serialize_date(value)
|
|
36
|
+
when Date
|
|
37
|
+
# A calendar date has no instant to rebuild: it travels as
|
|
38
|
+
# "YYYY-MM-DD", in strict mode too (Story 17-0c review).
|
|
39
|
+
serialize_string(value.iso8601)
|
|
36
40
|
when ClientReference
|
|
37
41
|
serialize_client_reference(value)
|
|
38
42
|
when SuspenseElement
|
|
@@ -53,6 +57,7 @@ module Ruact
|
|
|
53
57
|
# --- Primitives ---
|
|
54
58
|
|
|
55
59
|
def serialize_string(value)
|
|
60
|
+
value = Utf8.text(value)
|
|
56
61
|
# Large strings get their own T row
|
|
57
62
|
if value.bytesize >= LARGE_TEXT_THRESHOLD
|
|
58
63
|
id = @request.allocate_id
|
|
@@ -89,12 +94,13 @@ module Ruact
|
|
|
89
94
|
# Only :undefined is special for now
|
|
90
95
|
return "$undefined" if value == :undefined
|
|
91
96
|
|
|
92
|
-
#
|
|
93
|
-
|
|
97
|
+
# Any other symbol is its name — escaped like a string, so `:"$nope"`
|
|
98
|
+
# cannot pose as a "$n…" marker.
|
|
99
|
+
serialize_string(value.to_s)
|
|
94
100
|
end
|
|
95
101
|
|
|
96
102
|
def serialize_date(value)
|
|
97
|
-
"$D#{
|
|
103
|
+
"$D#{JsDate.iso8601(value)}"
|
|
98
104
|
end
|
|
99
105
|
|
|
100
106
|
# --- Collections ---
|
|
@@ -104,7 +110,7 @@ module Ruact
|
|
|
104
110
|
end
|
|
105
111
|
|
|
106
112
|
def serialize_hash(value)
|
|
107
|
-
value.transform_keys(
|
|
113
|
+
value.transform_keys { |key| Utf8.text(key.to_s) }.transform_values { |v| serialize_model(v) }
|
|
108
114
|
end
|
|
109
115
|
|
|
110
116
|
# --- React Element ---
|