├── .github └── workflows │ ├── build_js.yml │ ├── dynamic-security.yml │ └── publish_docs.yml ├── .gitignore ├── .tool-versions ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NEWS.md ├── README.md ├── SECURITY.md ├── docs ├── configuration.md ├── cross-cutting-concerns.md ├── deferments.md ├── demo.md ├── digging.md ├── images │ ├── demo.jpg │ ├── haircuts.png │ ├── no_apis.png │ ├── props_template.png │ ├── s-color.svg │ ├── superglue--one-color.svg │ ├── superglue--word-mark-one-color.svg │ ├── superglue--word-mark.svg │ ├── superglue.png │ └── superglue.svg ├── index.md ├── installation.md ├── navigation-context.md ├── page-response.md ├── rails-utils.md ├── recipes │ ├── infinite-scroll.md │ ├── modals.md │ ├── progress-bar.md │ ├── shopping-cart.md │ ├── spa-pagination.md │ ├── ssr.md │ ├── turbo.md │ └── vite.md ├── redux-state-shape.md ├── reference │ ├── README.md │ ├── components.Navigation.md │ ├── hooks.md │ ├── index.md │ ├── types.actions.md │ ├── types.md │ └── types.requests.md ├── requests.md ├── stylesheets │ └── extra.css ├── tutorial.md └── ujs.md ├── mkdocs.yml ├── package-lock.json └── superglue ├── .babelrc.js ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── lib ├── action_creators │ ├── index.ts │ └── requests.ts ├── actions.ts ├── components │ └── Navigation.tsx ├── config.ts ├── hooks │ └── index.ts ├── index.tsx ├── reducers │ └── index.ts ├── types │ ├── actions.ts │ ├── index.ts │ └── requests.ts └── utils │ ├── helpers.ts │ ├── immutability.ts │ ├── index.ts │ ├── request.ts │ ├── ujs.ts │ ├── url.ts │ └── window.ts ├── package.json ├── spec ├── features │ └── navigation.spec.jsx ├── fixtures.js ├── helpers │ ├── polyfill.js │ └── setup.js ├── lib │ ├── NavComponent.spec.jsx │ ├── action_creators.spec.js │ ├── hooks.spec.jsx │ ├── reducers.spec.js │ └── utils │ │ ├── helpers.spec.js │ │ ├── immutability.spec.js │ │ ├── request.spec.js │ │ ├── ujs.spec.js │ │ ├── url.spec.js │ │ └── window.spec.js └── support │ └── jasmine.json ├── tsconfig.json ├── tsup.config.ts ├── typedoc.json └── vitest.config.ts /.github/workflows/build_js.yml: -------------------------------------------------------------------------------- 1 | name: Test superglue_js 2 | on: 3 | push: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build: 9 | name: Test superglue.js 10 | strategy: 11 | fail-fast: false 12 | 13 | runs-on: 'ubuntu-latest' 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 18 20 | - name: Setup project 21 | working-directory: ./superglue 22 | run: npm install 23 | - name: Lint 24 | working-directory: ./superglue 25 | run: npm run lint 26 | - name: Test 27 | working-directory: ./superglue 28 | run: npm run test 29 | -------------------------------------------------------------------------------- /.github/workflows/dynamic-security.yml: -------------------------------------------------------------------------------- 1 | name: update-security 2 | 3 | on: 4 | push: 5 | paths: 6 | - SECURITY.md 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update-security: 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | pages: write 17 | uses: thoughtbot/templates/.github/workflows/dynamic-security.yaml@main 18 | secrets: 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/publish_docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs 2 | on: workflow_dispatch 3 | 4 | permissions: 5 | contents: write 6 | pages: write 7 | id-token: write 8 | 9 | concurrency: 10 | group: "pages" 11 | cancel-in-progress: false 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 18 21 | 22 | - name: npm install 23 | working-directory: ./superglue 24 | run: npm install 25 | 26 | - name: Build typedoc 27 | working-directory: ./superglue 28 | run: npx typedoc 29 | 30 | - uses: actions/setup-python@v5 31 | with: 32 | python-version: 3.x 33 | - run: pip install mkdocs-material 34 | - name: Build mkdoc 35 | run: mkdocs build --site-dir ./_site 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v3 38 | 39 | deploy: 40 | environment: 41 | name: github-pages 42 | url: ${{ steps.deployment.outputs.page_url }} 43 | runs-on: ubuntu-latest 44 | needs: build 45 | steps: 46 | - name: Deploy to GitHub Pages 47 | id: deployment 48 | uses: actions/deploy-pages@v4 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.lock 2 | node_modules 3 | dist 4 | pkg/ 5 | vendor/bundle 6 | test/log/ 7 | tmp/ 8 | .byebug_history 9 | log 10 | blade.yml 11 | breezy/build/**/*.js 12 | *.gem 13 | props_template/performance/**/*.png 14 | testapp/ 15 | 16 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 18.20.6 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # More details are here: https://help.github.com/articles/about-codeowners/ 5 | 6 | # The '*' pattern is global owners. 7 | 8 | # Order is important. The last matching pattern has the most precedence. 9 | # The folders are ordered as follows: 10 | 11 | # In each subsection folders are ordered first by depth, then alphabetically. 12 | # This should make it easy to add new rules without breaking existing ones. 13 | 14 | # Global rule: 15 | * @jho406 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of conduct 2 | 3 | By participating in this project, you agree to abide by the 4 | [thoughtbot code of conduct][1]. 5 | 6 | [1]: https://thoughtbot.com/open-source-code-of-conduct 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | ## Code of Conduct 4 | 5 | We welcome pull requests from everyone. By participating in this project, you 6 | agree to abide by the thoughtbot [code of conduct]. 7 | 8 | We expect everyone to follow the code of conduct anywhere in thoughtbot's 9 | project codebases, issue trackers, chat-rooms, and mailing lists. 10 | 11 | [code of conduct]: https://thoughtbot.com/open-source-code-of-conduct 12 | 13 | ### Opening a PR 14 | 15 | 1. Fork the repo, 16 | 2. Navigate to the `superglue` directory, 17 | 2. Run `npm install` to install the base dependencies, 18 | 3. Run the test suite: `npm run test`, 19 | 4. Make your changes, 20 | 5. Push your fork and open a pull request. 21 | 22 | A good PR will solve the smallest problem it possibly can and have good test 23 | coverage. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2025 Johny Ho 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 is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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 IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Logo 3 |
4 | 5 | # Superglue 6 | 7 | Use classic Rails to build rich React Redux applications with **NO APIs** and 8 | **NO client-side routing**. 9 | 10 | [![Test superglue_js](https://github.com/thoughtbot/superglue/actions/workflows/build_js.yml/badge.svg)](https://github.com/thoughtbot/superglue/actions/workflows/build_js.yml) 11 | 12 | Superglue makes React and Redux as productive as Hotwire, Turbo and Stimulus. 13 | Its inspired by Turbolinks and designed to feel like a natural extension of 14 | Rails. Enjoy the benefits of Redux state management and React components 15 | without giving up the productivity of Rails form helpers, UJS, tag helpers, 16 | flash, cookie auth, and more. 17 | 18 | ### No APIs 19 | 20 | Instead of APIs, Superglue leans on Rails' ability to respond to different 21 | [mime types](https://apidock.com/rails/ActionController/MimeResponds/InstanceMethods/respond_to) 22 | on the same route. In a Superglue application, if you direct your browser to 23 | `/dashboard.html`, you would see the HTML version of the content, and if you 24 | went to `/dashboard.json` you would see the JSON version of the exact same 25 | content down to the footer. 26 | 27 | The end result would be something like this: 28 | 29 | ![No Apis](https://thoughtbot.github.io/superglue/images/no_apis.png) 30 | 31 | ### Powered by Classic Rails 32 | Superglue leans on Rails. Features like the flash, cookie auth, and URL 33 | helpers continue to be useful. Here's a look at the directory structure of a 34 | typical Rails application with Superglue. 35 | 36 | ```treeview 37 | app/ 38 | |-- controllers/ 39 | |-- views/ 40 | | |-- dashboard/ 41 | | | |-- index.jsx # The React page component 42 | | | |-- index.json.props # The json for the page component 43 | | | |-- index.html.erb 44 | ``` 45 | 46 | ### PropsTemplate 47 | Powering the JSON responses is PropsTemplate, a diggable JSON templating DSL 48 | inspired by JBuilder. With PropsTemplate you can specify a path of the node you 49 | want, and PropsTemplate will walk the tree to it, skipping the execution of nodes 50 | that don't match the keypath. 51 | 52 | ![No Apis](https://thoughtbot.github.io/superglue/images/props_template.png) 53 | 54 | ### All together now! 55 | Superglue comes with batteries that bring all the above concepts together to make 56 | building popular SPA features easy, painless, and productive. 57 | 58 | #### SPA Navigation 59 | A popular ask of SPAs is page-to-page navigation without reloading. This is 60 | easily done with Superglue's own UJS attributes inspired by Turbolinks: 61 | 62 | ```jsx 63 | 64 | ``` 65 | 66 | The above will request for `/posts` with an `accept` of `application/json`, and 67 | when the client receives the response, swap out the current component for the 68 | component the response asks for, and `pushState` on history. 69 | 70 | 71 | #### Easy Partial updates 72 | Some features rely on updating some parts of the existing page. Imagine 73 | implementing type-ahead search. In traditional applications, you may need a new 74 | controller, routes, a discussion over versioning, JSON serializer, plenty of 75 | new JS code, etc. 76 | 77 | ![haircuts](https://thoughtbot.github.io/superglue/images/haircuts.png) 78 | 79 | With Superglue, this can be done with a simple `onChange` 80 | 81 | ```js 82 | import {NavigationContext} from '@thoughtbot/superglue' 83 | 84 | const {remote} = useContext(NavigationContext) 85 | 86 | const onChange = (e) => ( 87 | remote(`/dashboard?qry=${e.target.value}&props_at=data.header.search`)} 88 | ) 89 | ``` 90 | 91 | With `props_at`, the above will make a request to `/dashboard?qry=haircut`, 92 | dig your template for the `data.header.search` node, return it in the response, 93 | and immutably graft it in the exact same path on the redux store before finally 94 | letting React re-render. 95 | 96 | For more on what you can do, check out our documentation. 97 | 98 | #### Server-Side Rendering 99 | Server-Side Rendering is supported via [Humid](https://github.com/thoughtbot/humid). 100 | See the [documentation for server-side rendering][ssr docs]. 101 | 102 | [ssr docs]: ./recipes/server-side-rendering.md 103 | 104 | ## Documentation 105 | 106 | Documentation is hosted on [GitHub pages](https://thoughtbot.github.io/superglue). 107 | 108 | ## Contributing 109 | 110 | Please see [CONTRIBUTING.md](CONTRIBUTING.md). 111 | 112 | Thank you, [contributors]! 113 | 114 | [contributors]: https://github.com/thoughtbot/superglue/graphs/contributors 115 | 116 | ## Special Thanks 117 | 118 | Thanks to [jbuilder](https://github.com/rails/jbuilder), 119 | [scour](https://github.com/rstacruz/scour), 120 | [turbolinks3](https://github.com/turbolinks/turbolinks-classic), 121 | [turbograft](https://github.com/Shopify/turbograft/), 122 | [turbostreamer](https://github.com/malomalo/turbostreamer) 123 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | # Security Policy 3 | 4 | ## Supported Versions 5 | 6 | Only the the latest version of this project is supported at a given time. If 7 | you find a security issue with an older version, please try updating to the 8 | latest version first. 9 | 10 | If for some reason you can't update to the latest version, please let us know 11 | your reasons so that we can have a better understanding of your situation. 12 | 13 | ## Reporting a Vulnerability 14 | 15 | For security inquiries or vulnerability reports, visit 16 | . 17 | 18 | If you have any suggestions to improve this policy, visit . 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | You've installed Superglue and now you're ready to configure your app. 2 | 3 | ## `application_visit.js` 4 | 5 | !!! tip 6 | If you want a [progress bar], this is the first thing you'll want to 7 | configure after installation. 8 | 9 | This file contains the factory that builds the [remote] and [visit] 10 | function that will be passed to your page components and used by the 11 | [data-sg-visit] and [data-sg-remote] UJS attributes. 12 | 13 | This file is meant for you to customize. For example, you'll likely 14 | want to add a [progress bar], control how visits work, or flash 15 | when the internet is down. 16 | 17 | [remote]: requests.md#remote 18 | [visit]: requests.md#visit 19 | [data-sg-remote]: ujs.md#data-sg-remote 20 | [data-sg-visit]: ujs.md#data-sg-visit 21 | [progress bar]: recipes/progress-bar.md 22 | 23 | 24 | ## `page_to_page_mapping.js` 25 | 26 | !!! info 27 | Stop by the [tutorial] to learn how to work with this file. 28 | 29 | **Vite Users** This step can be entirely optional if you're using Vite. See 30 | the [recipe](recipes/vite.md) for more information. 31 | 32 | This file exports a mapping between a `componentIdentifier` to an imported page 33 | component. This gets used in your `application.js` so that superglue knows 34 | which component to render with which identifier. 35 | 36 | For example: 37 | 38 | ```js 39 | const pageIdentifierToPageComponent = { 40 | 'posts/edit': PostsEdit, 41 | 'posts/new': PostsNew, 42 | 'posts/show': PostsShow, 43 | 'posts/index': PostsIndex, 44 | } 45 | ``` 46 | 47 | [tutorial]: tutorial.md 48 | 49 | ## `application.js` 50 | 51 | This is the entry point of your application and uses Superglue's [Application] 52 | component. There's nothing to do here, but if you need finer control of 53 | how redux is setup, you can build your own Application using the [source] as 54 | inspiration. 55 | 56 | [source]: https://github.com/thoughtbot/superglue/blob/main/superglue/lib/index.tsx#L114 57 | 58 |
59 | - [:octicons-arrow-right-24: See complete reference](reference/index.md#application) 60 | for `Application` 61 |
62 | 63 | ## `flash.js` 64 | 65 | The installation generator will add a `flash.js` slice to `app/javascript/slices` 66 | and will work with the Rails `flash`. You can modify this however you like, out of the box: 67 | 68 | - When using `data-sg-visit`, all data in the flash slice will be cleared before the request. 69 | - When using `data-sg-visit` or `data-sg-remote`, the recieved flash 70 | will be merged with the current flash. You can change this behavior 71 | by modifying the flash slice. 72 | 73 | 74 | !!! hint 75 | If you're curious how this works, in your layout, `application.json.props`, 76 | the flash is serialized using `flash.to_h` 77 | 78 | 79 | To use in your page components, simply use a selector. 80 | 81 | ```jsx 82 | import { useSelector } from 'react-redux' 83 | 84 | ... 85 | 86 | const flash = useSelector((state) => state.flash) 87 | ``` 88 | 89 | then use the flash as you would normally in a controller 90 | 91 | ```ruby 92 | def create 93 | flash[:success] = "Post was saved!" 94 | end 95 | ``` 96 | 97 | [buildStore]: reference/index.md#buildstore 98 | [visitAndRemote]: requests.md 99 | [mapping]: reference/index.md#mapping 100 | [installation]: installation.md 101 | 102 | -------------------------------------------------------------------------------- /docs/cross-cutting-concerns.md: -------------------------------------------------------------------------------- 1 | # Cross cutting concerns 2 | 3 | ## Layouts 4 | 5 | If you have state that is shared between pages, simply put it in your layout. 6 | For example. In the generated `application.json.props` 7 | 8 | ```ruby 9 | path = request.format.json? ? param_to_dig_path(params[:props_at]) : nil 10 | 11 | json.data(dig: path) do 12 | json.temperature "HOT HOT HOT" 13 | yield json 14 | end 15 | ``` 16 | 17 | In the above, every page that gets rendered will have `temperature` as part of 18 | the [page response]. 19 | 20 | ## Partials 21 | 22 | We can also use partials to extract crosscutting concerns. For example, a shared header: 23 | 24 | ```treeview 25 | app/ 26 | |-- controllers/ 27 | |-- views/ 28 | | |-- shared/ 29 | | | |-- _header.json.props 30 | | |-- posts/ 31 | | | |-- index.js 32 | | | |-- index.json.props 33 | | |-- comments/ 34 | | | |-- index.js 35 | | | |-- index.json.props 36 | ``` 37 | 38 | By [design](./redux-state-shape.md) this results in duplicate JSON nodes 39 | across our `pages` slice: 40 | 41 | ```json 42 | { 43 | pages: { 44 | "/posts": { 45 | data: { 46 | header: { 47 | email: "foo@foo.com" 48 | } 49 | } 50 | }, 51 | "/comments": { 52 | data: { 53 | header: { 54 | email: "foo@foo.com" 55 | } 56 | } 57 | }, 58 | } 59 | } 60 | ``` 61 | 62 | 63 | ## Advanced functionality 64 | 65 | For most cases where you don't have to mutate your store, using layouts or 66 | partials would be good enough. Its a fine tradeoff for simplicity. 67 | 68 | Sometimes we have global concerns that we'd like to keep updated. This can be 69 | for across pages when navigating or if we'd like to perform client-side 70 | updates. For example, if we're showing a shopping cart quantity on the 71 | header, we want to keep that updated as we navigate back, and when updating 72 | line items locally. 73 | 74 | For this, Superglue has fragments and Redux slices. 75 | 76 | !!! hint 77 | You may not need to use fragments and Redux slices. For some apps, the only 78 | slices you'll ever need is the generated `flash.js` slice that comes with the 79 | install step. 80 | 81 | ## Fragments 82 | 83 | A fragment in Superglue is any props_template block with given name: 84 | 85 | ``` 86 | json.body do 87 | json.cart(fragment: "shoppingCart"]) do 88 | end 89 | end 90 | ``` 91 | 92 | Now whenever we encounter a fragment from a new `visit` or update a fragment using `remote`, 93 | Superglue will dispatch an `updateFragment` action. 94 | 95 |
96 | - [:octicons-arrow-right-24: See reference](reference/index.md#updatefragments) 97 | for `updateFragments` 98 |
99 | 100 | That's not a very useful thing by itself, but when combined with Redux toolkit 101 | [createSlice] and [useSelector], it offers a way to easily build global 102 | concerns. 103 | 104 | [useSelector]: https://react-redux.js.org/api/hooks#useselector 105 | [createSlice]: https://redux-toolkit.js.org/api/createSlice 106 | 107 | 108 | ## Slices 109 | 110 | Whenever a fragment is received or updated, a `UPDATE_FRAGMENTS` action is 111 | dispatched with the value. You can return that value as your state to 112 | keep your slice updated as the user navigates. 113 | 114 | For example: 115 | 116 | ```javascript 117 | import { createSlice } from '@reduxjs/toolkit' 118 | import { updateFragment } from '@thoughtbot/superglue' 119 | 120 | export const cartSlice = createSlice({ 121 | name: 'cart', 122 | extraReducers: (builder) => { 123 | builder.addCase(updateFragments, (state, action) => { 124 | const { value, name } = action.payload; 125 | 126 | if (name === "cart") { 127 | return value 128 | } else { 129 | return state; 130 | } 131 | }) 132 | } 133 | }) 134 | ``` 135 | 136 | Then somewhere in a component you can [useSelector]: 137 | 138 | ``` 139 | import { useSelector } from 'react-redux' 140 | 141 | ... 142 | 143 | const cart = useSelector((state) => state.cart) 144 | ``` 145 | 146 | 147 | And as this is just a normal Redux [slice], you can also add custom [reducers] 148 | to the mix for client-side updates. 149 | 150 | [useSelector]: https://redux-toolkit.js.org/tutorials/quick-start#use-redux-state-and-actions-in-react-components 151 | [slice]: https://redux-toolkit.js.org/api/createSlice 152 | [reducers]: https://redux-toolkit.js.org/api/createSlice#reducers 153 | 154 | ### initialState 155 | 156 | You can render your slice's initial state in the [slices] `key` of the page 157 | object, it'll be merged with the `initialState` passed to your `buildStore` 158 | function in your [application.js](./configuration.md#applicationjs) 159 | 160 | [slices]: ./page-response.md#slices 161 | 162 | ## Other actions 163 | 164 | Aside from `UPDATE_FRAGMENTS`, superglue comes with other actions that get 165 | dispatched during lifecycle events that you can make use of. The `flashSlice` 166 | that was generated with the installation is a good example of this. 167 | 168 | To higlight a few: 169 | 170 | 171 | `BEFORE_FETCH` - Action created before a before a fetch is called. 172 | 173 | ``` 174 | { 175 | type: "@@superglue/BEFORE_FETCH", 176 | payload: [..array args that are passed to fetch] 177 | } 178 | ``` 179 | 180 | `BEFORE_VISIT` - Same as above, but called only for a `visit` action. 181 | 182 | ``` 183 | { 184 | type: "@@superglue/BEFORE_VISIT", 185 | payload: [..array args that are passed to fetch] 186 | } 187 | ``` 188 | 189 | `BEFORE_REMOTE` - Same as above, but called only a `remote` action. 190 | 191 | ``` 192 | { 193 | type: "@@superglue/BEFORE_REMOTE", 194 | payload: [..array args that are passed to fetch] 195 | } 196 | ``` 197 | 198 | `SAVE_RESPONSE` - Whenever a [page response] is received. 199 | 200 | ``` 201 | { 202 | type: "@@superglue/SAVE_RESPONSE", 203 | payload: { 204 | pageKey: "/posts", 205 | page: {...the page response}, 206 | }, 207 | } 208 | ``` 209 | 210 | [page response]: ./page-response.md 211 | [extraReducers]: https://redux-toolkit.js.org/api/createSlice#extrareducers 212 | 213 | -------------------------------------------------------------------------------- /docs/deferments.md: -------------------------------------------------------------------------------- 1 | Sometimes you may want to load parts of your page later, like a slow sidebar, a 2 | graph that takes extra time to load, or tab content that shouldn't appear 3 | immediately. These scenarios are perfect use cases for Deferments. 4 | 5 | Deferments are a low effort way to load content later, both automatically and 6 | manually. Better yet, most of the work takes place in Rails land in your views. 7 | 8 | ## `defer: :auto` 9 | 10 | This option make it easy to defer content in a single setting. 11 | 12 | === "views/posts/index.json.props" 13 | 14 | ``` ruby 15 | json.metrics(defer: [:auto, placeholder: {totalVisitors: 0}]) do 16 | sleep 10 # expensive operation 17 | json.totalVisitors 30 18 | end 19 | ``` 20 | 21 | === "views/layouts/application.json.props" 22 | 23 | ``` ruby 24 | json.data do 25 | yield 26 | end 27 | ``` 28 | 29 | And that's it! 30 | 31 | ### Behind the scenes 32 | 33 | When a user lands on a page Superglue will receive 34 | 35 | ```json 36 | { 37 | data: { 38 | metrics: { 39 | totalVisitors: 0 40 | } 41 | }, 42 | defers:[ 43 | {url: '/dashboard?props_at=data.metrics', type: "auto"} 44 | ], 45 | ...other 46 | } 47 | ``` 48 | 49 | Your page components will receive `{metrics: {totalVisitors: 0}}` and render. Superglue will then 50 | make a remote request: 51 | 52 | ``` 53 | remote("/dashboard?props_at=data.metrics") 54 | ``` 55 | 56 | 10 seconds later the response succeeds with `{total_visitors: 30}`. Superglue 57 | then immutably grafts that payload into the `/dashboard` page at the path 58 | `data.metrics`. The page state would look like the following: 59 | 60 | ``` 61 | { 62 | data: { 63 | metrics: { 64 | totalVisitors: 30 65 | } 66 | }, 67 | defers:[...others], 68 | ...other 69 | } 70 | ``` 71 | 72 | Your page component finally recieves the new props and rerenders. For more 73 | control, you may provide a `success_action` or `fail_action`, and Superglue 74 | will dispatch these actions when the promise resolves successfully or fails. 75 | 76 | ```ruby 77 | json.metrics(defer: [:auto, placeholder: {totalVisitors: 0}, success_action: "SUCCESS", fail_action: "FAIL"]) do 78 | sleep 10 # expensive operation 79 | json.totalVisitors 30 80 | end 81 | ``` 82 | 83 | ## `defer: :manual` 84 | 85 | When you want control over when deferred content loads, e.g., tabbed content, 86 | use `defer: :manual` to stop the content from loading 87 | 88 | ```ruby 89 | json.metrics(defer: [:manual, placeholder: {totalVisitors: 0}]) do 90 | sleep 10 # expensive operation 91 | json.totalVisitors 30 92 | end 93 | ``` 94 | 95 | and manually use `remote` 96 | 97 | ``` 98 | remote("/dashboard?props_at=data.metrics") 99 | ``` 100 | 101 | 102 | -------------------------------------------------------------------------------- /docs/demo.md: -------------------------------------------------------------------------------- 1 | # Demo Application 2 | 3 | We have a non-trivial [demo] application built using Superglue and the original 4 | [Rails and StimulusJS] version built by [Sean Doyle] The intent is to help you 5 | compare and contrast both approaches and showcase how enjoyable and 6 | Rails-like Superglue/React/Redux can be. 7 | 8 | ![Demo App](images/demo.jpg) 9 | 10 | We recommend going over the meticulously verbose commit history on [Sean's 11 | version] and comparing that with the [Superglue version]. 12 | 13 | [demo]: https://github.com/thoughtbot/select-your-own-seat-superglue 14 | [Rails and StimulusJS]: https://github.com/seanpdoyle/select-your-own-seat 15 | [Sean Doyle]: https://github.com/seanpdoyle 16 | [Sean's version]: https://github.com/seanpdoyle/select-your-own-seat/commits/main 17 | [Superglue version]: https://github.com/thoughtbot/select-your-own-seat-superglue/commits/superglue 18 | 19 | -------------------------------------------------------------------------------- /docs/digging.md: -------------------------------------------------------------------------------- 1 | # Digging 2 | 3 | Beyond full page navigation, Superglue can make selective updates to parts of 4 | the page without a full load through digging. You may recognize digging from 5 | earlier docs: 6 | 7 | ``` 8 | /some_current_page?props_at=data.rightDrawer.dailySpecials 9 | ``` 10 | 11 | By simply adding a `props_at` parameter to your requests, you can selectively 12 | fetch parts of the page without incurring the cost of loading unneeded content. This 13 | is great for functionality like modals, tabs, etc. 14 | 15 | ## The `props_at` param 16 | 17 | The `props_at` param is a keypath to the content in your PropsTemplate. As a simplified 18 | example, imagine this page with no layouts: 19 | 20 | ```ruby 21 | path = param_to_dig_path(params[:props_at]) 22 | json.data(dig: path) do 23 | json.header do 24 | json.search do 25 | # Results is a leaf node 26 | json.results Post.search(params[:some_search_str]) 27 | end 28 | end 29 | 30 | json.content do 31 | json.barChart do 32 | ...bar chart data 33 | end 34 | 35 | ... 36 | end 37 | 38 | ... 39 | end 40 | ``` 41 | 42 | To fetch the `json.search` node, we would need to walk to `data` then `header` 43 | then `search`. Translating that to a url with a `props_at` param: 44 | 45 | ``` 46 | /dashboard?props_at=data.header.search&some_search_str=haircuts 47 | ``` 48 | 49 | Digging is normally combined with using [data-sg-remote] or [remote] to update 50 | content in async fashion. 51 | 52 | !!! info 53 | `props_at` can be used with `data-sg-visit` 54 | 55 | [data-sg-remote]: ./ujs.md#data-sg-remote 56 | [remote]: ./requests.md#remote 57 | 58 | 59 | ## Collections 60 | There are two ways to query collections. Looking at the following example: 61 | 62 | ```ruby 63 | path = param_to_dig_path(params[:props_at]) 64 | json.data(dig: path) do 65 | json.posts do 66 | json.array! @posts do |post| 67 | json.details do 68 | json.title post.title 69 | end 70 | end 71 | end 72 | end 73 | ``` 74 | 75 | ### Index-based selection 76 | You may use an index-based key to fetch an item in a list like so: 77 | 78 | ```js 79 | remote('/dashboard?props_at=data.posts.0.details') 80 | ``` 81 | 82 | To enable this functionality, you are required to implement `member_at(index)` 83 | on the passed collection. 84 | 85 | ?> PropsTemplate includes a `Array` extension which delegates to `at`. If you've 86 | used the Superglue generators, it will be included in an initializer. 87 | 88 | While traversing by index works fine, it can lead the wrong post being updated 89 | if your Redux state has changed by the time the request comes back. 90 | 91 | ### Attribute-based selection 92 | Attribute-based keys for collections look like this: 93 | 94 | ```js 95 | remote('/dashboard?props_at=data.posts.some_id=1.details') 96 | ``` 97 | 98 | Notice that we're now referencing the collection member by `some_id=1` instead 99 | of index. This will fetch the node from the backend and graft it correctly in 100 | Redux. 101 | 102 | To enable this, you are required to implement `member_by(attribute, value)` on 103 | the passed collection AND use the option `:key` in `json.array!`. For example: 104 | 105 | ```ruby 106 | path = param_to_dig_path(params[:props_at]) 107 | json.data(dig: params[:props_at]) do 108 | json.posts do 109 | json.array! @posts, key: :some_id do |post| 110 | json.details do 111 | json.title post.title 112 | end 113 | 114 | # The following will be auto appended by the key: option 115 | # json.some_id post.some_id 116 | end 117 | end 118 | end 119 | ``` 120 | 121 | ## Partials 122 | 123 | You can even query into partials. 124 | 125 | ```js 126 | remote('/dashboard?props_at=data.posts.some_id=1.details') 127 | ``` 128 | 129 | ```ruby 130 | json.data(dig: params[:props_at]) do 131 | json.posts(partial: 'list_of_posts')do 132 | end 133 | end 134 | ``` 135 | 136 | ```ruby 137 | # list_of_posts.json.props 138 | json.array! @posts , key: :some_id do |post| 139 | json.details do 140 | json.title post.title 141 | end 142 | 143 | # The following will be auto appended by the key: option 144 | # json.some_id post.some_id 145 | end 146 | ``` 147 | 148 | !!! info 149 | When querying, Superglue will disable 150 | [caching](https://github.com/thoughtbot/props_template#caching) and 151 | [deferment](https://github.com/thoughtbot/props_template#deferment) until the 152 | target node is reached. 153 | 154 | With digging, many modern SPA functionality can be achieved by just a keypath and a 155 | few lines of code. 156 | 157 | [PropsTemplate]: https://github.com/thoughtbot/props_template 158 | 159 | -------------------------------------------------------------------------------- /docs/images/demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/superglue/1dd67e4526e03004ad3efc6f367c9a0caa02942b/docs/images/demo.jpg -------------------------------------------------------------------------------- /docs/images/haircuts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/superglue/1dd67e4526e03004ad3efc6f367c9a0caa02942b/docs/images/haircuts.png -------------------------------------------------------------------------------- /docs/images/no_apis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/superglue/1dd67e4526e03004ad3efc6f367c9a0caa02942b/docs/images/no_apis.png -------------------------------------------------------------------------------- /docs/images/props_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/superglue/1dd67e4526e03004ad3efc6f367c9a0caa02942b/docs/images/props_template.png -------------------------------------------------------------------------------- /docs/images/s-color.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /docs/images/superglue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/superglue/1dd67e4526e03004ad3efc6f367c9a0caa02942b/docs/images/superglue.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Superglue and friends [thoughtfully pairs Rails and React]. Its built with a 4 | laser focus on The Rails Way and aims to provide a simple developer experience 5 | on par with Hotwire, Stimulus, and Turbo. Confidently use Rails routes, 6 | controllers, views as you normally would in a multi-page application and 7 | integrate with React's vast ecosystem. 8 | 9 | ## Who is it for? 10 | 11 | Superglue is built from the ground up for 12 | 13 | - **The Rails developer**: For those of us who want to harness the full power 14 | of ALL of Rails --controllers, server-side routing, views, form helpers, 15 | and more — to create seamless, interactive React applications **without** the 16 | hassle of APIs and client side routing. 17 | 18 | - **Teams fighting complexity**: Its not easy pivoting from complexity. 19 | Superglue empowers teams to take small steps back without giving up the 20 | effort invested in React components. 21 | 22 | - **Startups moving fast**: Founders looking to hit the ground running by 23 | combining the speed of Rails development with React's vast ecosystem of 24 | prebuilt UI libraries. 25 | 26 | - **Javascript fatigue**: Anyone tired of JS complexity and just want to get 27 | work done. 28 | 29 | 30 | ## How does it work? 31 | 32 | ### It’s Rails 33 | 34 | Superglue leans on Rails' ability to respond to different mime types on the 35 | same route and divides the usual `foobar.html.erb` into three familiar 36 | templates. 37 | 38 | - `foobar.json.props` A presenter written in a jbuilder-like template that 39 | builds your page props. 40 | - `foobar.(jsx|tsx)` Your page component that receives the props from above. 41 | - `foobar.html.erb` Injects your page props into Redux when the browser loads 42 | it. 43 | 44 | Shape your `props` to roughly match your component structure. For example: 45 | 46 | ```ruby 47 | json.header do 48 | json.username @user.username 49 | json.linkToProfile url_for(@user) 50 | end 51 | 52 | json.rightDrawer do 53 | json.cart(partial: 'cart') do 54 | end 55 | json.dailySpecials(partial: 'specials') do 56 | end 57 | end 58 | 59 | json.body do 60 | json.productFilter do 61 | form_props(url: "/", method: "GET") do |f| 62 | f.select(:category, ["lifestyle", "programming", "spiritual"]) 63 | f.submit 64 | end 65 | end 66 | 67 | json.products do 68 | json.array! @products do |product| 69 | json.title product.title 70 | json.urlToProduct url_for(product) 71 | end 72 | end 73 | end 74 | 75 | json.footer do 76 | json.copyrightYear "2023" 77 | end 78 | ``` 79 | 80 | Familiar Rails conveniences include form_props (a fork of `form_with` made for React), 81 | flash messages integrated as a Redux [slice], and [Unobtrusive Javascript][UJS] helpers. 82 | 83 | ### It’s React 84 | 85 | But there are no APIs! The above is injected as a script tag in the DOM so everything 86 | loads in the initial request. Its added to your [Redux state] and passed to 87 | the page component in a hook, for example: 88 | 89 | ```js 90 | import React from 'react'; 91 | import { useSelector } from 'react-redux'; 92 | import { Drawer, Header, Footer, ProductList, ProductFilter } from './components'; 93 | import { useContent } from '@thoughtbot/superglue' 94 | 95 | export default function FooBar() { 96 | const { 97 | header, 98 | products = [], 99 | productFilter, 100 | rightDrawer, 101 | footer 102 | } = useContent() 103 | 104 | const flash = useSelector((state) => state.flash); 105 | 106 | return ( 107 | <> 108 |

{flash && flash.notice}

109 |
110 | 111 |
112 | 113 | 114 | 115 | 116 | 117 |