├── .changeset ├── README.md ├── big-pugs-bake.md └── config.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.yaml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── config.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierrc.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jsconfig.json ├── mount-tmp-playground.sh ├── package.json ├── packages └── svelte-hmr │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── index.js │ ├── lib │ ├── css-only.js │ └── make-hot.js │ ├── package.json │ └── runtime │ ├── hot-api-esm.js │ ├── hot-api.js │ ├── index.js │ ├── overlay.js │ ├── proxy-adapter-dom.js │ ├── proxy.js │ ├── svelte-hooks.js │ └── svelte-native │ ├── patch-page-show-modal.js │ └── proxy-adapter-native.js ├── playground ├── $test │ ├── config.js │ ├── helpers.js │ ├── hmr-context.js │ ├── hmr-macro.js │ ├── index.js │ └── util.js ├── basic │ ├── $set.spec.js │ ├── action.spec.js │ ├── basic.spec.js │ ├── bindings.spec.js │ ├── bug.spec.js │ ├── callbacks.spec.js │ ├── context.spec.js │ ├── empty-component.spec.js │ ├── index.html │ ├── keyed-list.spec.js │ ├── lifecycle.spec.js │ ├── local-state-preserveLocalStateKey.spec.js │ ├── local-state.spec.js │ ├── module-context.spec.js │ ├── option-accessors.spec.js │ ├── outro.spec.js │ ├── package.json │ ├── props.spec.js │ ├── reactive-statements.spec.js │ ├── revival.spec.js │ ├── simultaneous-update-parent-child.spec.js │ ├── slots.spec.js │ ├── src │ │ ├── App.svelte │ │ └── main.js │ ├── stores.spec.js │ ├── style.spec.js │ ├── svelte-component.spec.js │ ├── transform-regex.spec.js │ └── vite.config.js ├── jsconfig.json ├── package.json ├── vitest.config.js ├── vitestGlobalSetup.js └── vitestSetup.js ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/big-pugs-bake.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'svelte-hmr': minor 3 | --- 4 | 5 | Rewrite tests to get rid of old vulnerable (dev) dependencies, and update CI setup 6 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json", 3 | "changelog": ["@svitejs/changesets-changelog-github-compact", { "repo": "sveltejs/svelte-hmr" }], 4 | "commit": false, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "master", 8 | "updateInternalDependencies": "patch" 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | sourceType: module 3 | ecmaVersion: latest 4 | 5 | # overrides: 6 | # - files: 7 | # - "packages/svelte-hmr/index.js" 8 | # - "packages/svelte-hmr/lib/**/*" 9 | # - "**/rollup.config.js" 10 | # env: 11 | # node: true 12 | 13 | env: 14 | node: true 15 | # es2022: true 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: svelte 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Svelte Discord 4 | url: https://svelte.dev/chat 5 | about: If you want to chat, join our discord. 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # build and test 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | env: 13 | # we call `pnpm playwright install` instead 14 | PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' 15 | 16 | jobs: 17 | # "checks" job runs on linux + 16 only and checks that install, build, lint and audit work 18 | # it also primes the pnpm store cache for linux, important for downstream tests 19 | checks: 20 | timeout-minutes: 5 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | matrix: 24 | # pseudo-matrix for convenience, NEVER use more than a single combination 25 | node: [16] 26 | os: [ubuntu-latest] 27 | outputs: 28 | build_successful: ${{ steps.build.outcome == 'success' }} 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: pnpm/action-setup@v4 32 | - uses: actions/setup-node@v4 33 | with: 34 | node-version: ${{ matrix.node }} 35 | cache: 'pnpm' 36 | - name: install 37 | run: pnpm install --frozen-lockfile --prefer-offline 38 | # reactivate this when there is a build step 39 | # - name: build 40 | # id: build 41 | # run: pnpm run build 42 | - name: lint 43 | if: (${{ success() }} || ${{ failure() }}) 44 | run: pnpm run lint 45 | - name: audit 46 | if: (${{ success() }} || ${{ failure() }}) 47 | run: pnpm audit 48 | 49 | # this is the test matrix 50 | # it is skipped if the build step of the checks job wasn't successful (still runs if lint or audit fail) 51 | test: 52 | needs: checks 53 | if: (${{ success() }} || ${{ failure() }}) && (${{ needs.checks.output.build_successful }}) 54 | timeout-minutes: 10 55 | runs-on: ${{ matrix.os }} 56 | strategy: 57 | fail-fast: false 58 | matrix: 59 | os: [ ubuntu-latest, macos-latest ] 60 | node: [ 16, 18, 20, 22 ] 61 | steps: 62 | - uses: actions/checkout@v4 63 | - uses: pnpm/action-setup@v4 64 | - uses: actions/setup-node@v4 65 | with: 66 | node-version: ${{ matrix.node }} 67 | cache: 'pnpm' 68 | - name: use svelte 3 69 | if: matrix.svelte == 3 70 | run: | 71 | tmppkg="$(jq '.devDependencies.svelte = "^3.59.2"' package.json)" && echo -E "${tmppkg}" > package.json && tmppkg="" 72 | - name: install 73 | if: matrix.node != 14 && matrix.svelte != 3 74 | run: pnpm install --frozen-lockfile --prefer-offline 75 | - name: install for node14 or svelte3 76 | if: matrix.node == 14 || matrix.svelte == 3 77 | run: pnpm install --no-frozen-lockfile --prefer-offline 78 | - name: install playwright chromium 79 | run: cd playground && pnpm playwright install chromium 80 | - name: run tests 81 | run: pnpm test 82 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | # prevents this action from running on forks 11 | if: github.repository == 'sveltejs/svelte-hmr' 12 | name: Release 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | # pseudo-matrix for convenience, NEVER use more than a single combination 17 | node: [20] 18 | os: [ubuntu-latest] 19 | steps: 20 | - name: checkout 21 | uses: actions/checkout@v4 22 | with: 23 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 24 | fetch-depth: 0 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node }} 28 | - name: install pnpm 29 | shell: bash 30 | run: | 31 | PNPM_VER=$(jq -r '.packageManager | if .[0:5] == "pnpm@" then .[5:] else "packageManager in package.json does not start with pnpm@\n" | halt_error(1) end' package.json) 32 | echo installing pnpm version $PNPM_VER 33 | npm i -g pnpm@$PNPM_VER 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: ${{ matrix.node }} 37 | cache: 'pnpm' 38 | cache-dependency-path: '**/pnpm-lock.yaml' 39 | - name: install 40 | run: pnpm install --frozen-lockfile --prefer-offline 41 | 42 | - name: Creating .npmrc 43 | run: | 44 | cat << EOF > "$HOME/.npmrc" 45 | //registry.npmjs.org/:_authToken=$NPM_TOKEN 46 | EOF 47 | env: 48 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | - name: Create Release Pull Request or Publish to npm 50 | id: changesets 51 | uses: changesets/action@v1 52 | with: 53 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 54 | publish: pnpm release 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | 59 | # TODO alert discord 60 | # - name: Send a Slack notification if a publish happens 61 | # if: steps.changesets.outputs.published == 'true' 62 | # # You can do something when a publish happens. 63 | # run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!" 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # logs and temp 2 | *.log 3 | **/*.log 4 | *.cpuprofile 5 | **/*.cpuprofile 6 | temp 7 | **/temp 8 | *.tmp 9 | **/*.tmp 10 | 11 | # build and dist 12 | build 13 | **/build 14 | dist 15 | **/dist 16 | 17 | # env and local 18 | *.local 19 | **/*.local 20 | .env 21 | **/.env 22 | 23 | #node_modules and pnpm 24 | node_modules 25 | **/node_modules 26 | # only workspace root has a lock 27 | pnpm-lock.yaml 28 | !/pnpm-lock.yaml 29 | .pnpm-store 30 | **/.pnpm-store 31 | 32 | #ide 33 | .idea 34 | **/.idea 35 | .vscode 36 | **/.vscode 37 | 38 | # macos 39 | .DS_Store 40 | ._.DS_Store 41 | **/.DS_Store 42 | **/._.DS_Store 43 | 44 | # misc 45 | *.bak.* 46 | *.bak 47 | *.orig 48 | 49 | # temporary playground files 50 | /playground-*/ 51 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false 2 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - prettier-plugin-jsdoc 3 | 4 | semi: false 5 | singleQuote: true 6 | trailingComma: es5 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to svelte-hmr 2 | 3 | svelte-hmr provides low-level tooling to enable hot module reloading with svelte 4 | 5 | - [Svelte](https://svelte.dev/) is a new way to build web applications. It's a compiler that takes your declarative components and converts them into efficient JavaScript that surgically updates the DOM. 6 | 7 | The [Open Source Guides](https://opensource.guide/) website has a collection of resources for individuals, communities, and companies. These resources help people who want to learn how to run and contribute to open source projects. Contributors and people new to open source alike will find the following guides especially useful: 8 | 9 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 10 | - [Building Welcoming Communities](https://opensource.guide/building-community/) 11 | 12 | ## Get involved 13 | 14 | There are many ways to contribute to svelte-hmr, and many of them do not involve writing any code. Here's a few ideas to get started: 15 | 16 | - Simply start using svelte-hmr. Does everything work as expected? If not, we're always looking for improvements. Let us know by [opening an issue](#reporting-new-issues). 17 | - Look through the [open issues](https://github.com/rixo/svelte-hmr/issues). Provide workarounds, ask for clarification, or suggest labels. Help [triage issues](#triaging-issues-and-pull-requests). 18 | - If you find an issue you would like to fix, [open a pull request](#your-first-pull-request). 19 | - Read through our [documentation](https://github.com/rixo/svelte-hmr/tree/main/docs). If you find anything that is confusing or can be improved, open a pull request. 20 | - Take a look at the [features requested](https://github.com/rixo/svelte-hmr/labels/enhancement) by others in the community and consider opening a pull request if you see something you want to work on. 21 | 22 | Contributions are very welcome. If you think you need help planning your contribution, please ping us on Discord at [svelte.dev/chat](https://svelte.dev/chat) and let us know you are looking for a bit of help. 23 | 24 | ### Triaging issues and pull requests 25 | 26 | One great way you can contribute to the project without writing any code is to help triage issues and pull requests as they come in. 27 | 28 | - Ask for more information if you believe the issue does not provide all the details required to solve it. 29 | - Suggest [labels](https://github.com/rixo/svelte-hmr/labels) that can help categorize issues. 30 | - Flag issues that are stale or that should be closed. 31 | - Ask for test plans and review code. 32 | 33 | ## Bugs 34 | 35 | We use [GitHub issues](https://github.com/rixo/svelte-hmr/issues) for our public bugs. If you would like to report a problem, take a look around and see if someone already opened an issue about it. If you are certain this is a new unreported bug, you can submit a [bug report](#reporting-new-issues). 36 | 37 | If you have questions about using svelte-hmr, contact us on Discord at [svelte.dev/chat](https://svelte.dev/chat), and we will do our best to answer your questions. 38 | 39 | If you see anything you'd like to be implemented, create a [feature request issue](https://github.com/rixo/svelte-hmr/issues/new?template=feature_request.md) 40 | 41 | ## Reporting new issues 42 | 43 | When [opening a new issue](https://github.com/sveltejs/svelte/issues/new/new?template=bug_report.md), always make sure to fill out the issue template. **This step is very important!** Not doing so may result in your issue not being managed in a timely fashion. Don't take this personally if this happens, and feel free to open a new issue once you've gathered all the information required by the template. 44 | 45 | - **One issue, one bug:** Please report a single bug per issue. 46 | - **Provide reproduction steps:** List all the steps necessary to reproduce the issue. The person reading your bug report should be able to follow these steps to reproduce your issue with minimal effort. 47 | 48 | ## Installation 49 | 50 | 1. This project uses [pnpm](https://pnpm.js.org/en/). Install it with `npm i -g pnpm` 51 | 1. After cloning the repo run `pnpm install` to install dependencies 52 | 53 | ## Pull requests 54 | 55 | ### Your first pull request 56 | 57 | So you have decided to contribute code back to upstream by opening a pull request. You've invested a good chunk of time, and we appreciate it. We will do our best to work with you and get the PR looked at. 58 | 59 | Working on your first Pull Request? You can learn how from this free video series: 60 | 61 | [**How to Contribute to an Open Source Project on GitHub**](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github) 62 | 63 | ### Proposing a change 64 | 65 | If you would like to request a new feature or enhancement but are not yet thinking about opening a pull request, you can also file an issue with [feature template](https://github.com/rixo/svelte-hmr/issues/new?template=feature_request.md). 66 | 67 | If you're only fixing a bug, it's fine to submit a pull request right away but we still recommend that you file an issue detailing what you're fixing. This is helpful in case we don't accept that specific fix but want to keep track of the issue. 68 | 69 | ### Sending a pull request 70 | 71 | Small pull requests are much easier to review and more likely to get merged. Make sure the PR does only one thing, otherwise please split it. 72 | 73 | Please make sure the following is done when submitting a pull request: 74 | 75 | 1. Fork [the repository](https://github.com/rixo/svelte-hmr) and create your branch from `main`. 76 | 1. Describe your **test plan** in your pull request description. Make sure to test your changes. 77 | 1. Make sure your code lints (`pnpm run lint`). 78 | 1. Make sure your tests pass (`pnpm run test`). 79 | 80 | All pull requests should be opened against the `main` branch. 81 | 82 | #### Tests 83 | 84 | Integration tests for new features or regression tests as part of a bug fix are very welcome. 85 | Add them in `test`. 86 | 87 | #### Documentation 88 | 89 | If you've changed APIs, update the documentation. 90 | 91 | #### Changelogs 92 | 93 | For changes to be reflected in package changelogs, run `pnpm changeset` and follow the prompts. 94 | You should always select the packages you've changed, Most likely `svelte-hmr`. 95 | 96 | ### What happens next? 97 | 98 | The core Svelte team will be monitoring for pull requests. Do help us by making your pull request easy to review by following the guidelines above. 99 | 100 | ## Style guide 101 | 102 | [Eslint](https://eslint.org) will catch most styling issues that may exist in your code. You can check the status of your code styling by simply running `pnpm run lint`. 103 | 104 | ## License 105 | 106 | By contributing to svelte-hmr, you agree that your contributions will be licensed under its [ISC license](./LICENSE). 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 - 2020, rixo and the svelte-hmr contributors. 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION 12 | OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 13 | CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svelte-hmr 2 | 3 | HMR engine for Svelte. 4 | 5 | See [svelte-hmr](/packages/svelte-hmr#readme) package for all details. 6 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "include": ["packages/**/*", "playground/**/*"], 4 | "exclude": ["node_modules", "**/*.bak"], 5 | "compilerOptions": { 6 | "checkJs": true, 7 | "moduleResolution": "node16", 8 | "module": "es2022", 9 | "resolveJsonModule": true, 10 | "skipLibCheck": true, 11 | //"noUnusedLocals": true, 12 | //"types": [] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /mount-tmp-playground.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Mount a tmpfs on playground-tmp directory. 4 | # 5 | # This is entirely optional. The point is to save write cycles on your disk. 6 | # 7 | # Usage: 8 | # 9 | # export TMP_PLAYGROUND_DIR=playground-tmp 10 | # ./mount-tmp-playground.sh 11 | # cd playground 12 | # pnpm test 13 | # 14 | 15 | TMP_PLAYGROUND_DIR=${TMP_PLAYGROUND_DIR:-"playground-tmp"} 16 | 17 | if [ "$1" == "-u" ] || [ "$1" == "--unmount" ]; then 18 | umount "$TMP_PLAYGROUND_DIR" 19 | else 20 | mkdir -p "$TMP_PLAYGROUND_DIR" 21 | mount -o size=16G -t tmpfs none playground-tmp 22 | fi 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-hmr-monorepo", 3 | "private": true, 4 | "description": "Bundler agnostic HMR utils for Svelte 3", 5 | "license": "ISC", 6 | "homepage": "https://github.com/sveltejs/svelte-hmr", 7 | "bugs": { 8 | "url": "https://github.com/sveltejs/svelte-hmr/issues" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/sveltejs/svelte-hmr" 13 | }, 14 | "packageManager": "pnpm@8.14.0", 15 | "engines": { 16 | "pnpm": ">=7.0.0" 17 | }, 18 | "pnpm": { 19 | "overrides": { 20 | "svelte-hmr": "workspace:*", 21 | "svelte": "$svelte" 22 | } 23 | }, 24 | "devDependencies": { 25 | "@changesets/cli": "^2.27.8", 26 | "@svitejs/changesets-changelog-github-compact": "^0.1.1", 27 | "@tsconfig/svelte": "^4.0.1", 28 | "eslint": "^8.44.0", 29 | "prettier": "^2.8.8", 30 | "prettier-plugin-jsdoc": "^0.4.2", 31 | "svelte": "^4.0.0", 32 | "svelte-check": "^3.4.4", 33 | "typescript": "^5.0.4" 34 | }, 35 | "scripts": { 36 | "release": "pnpm changeset publish", 37 | "lint": "pnpm --recursive lint", 38 | "test": "pnpm --recursive test" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/svelte-hmr/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # svelte-hmr 2 | 3 | ## 0.16.0 4 | 5 | ### Minor Changes 6 | 7 | - Update native adapter for support of NativeScript 8 ([`b8aabc0`](https://github.com/sveltejs/svelte-hmr/commit/b8aabc09d87821a10095224d4ab11fb83d7b243c)) 8 | 9 | ## 0.15.3 10 | 11 | ### Patch Changes 12 | 13 | - Fix injecting imports whose paths contain special characters ([#78](https://github.com/sveltejs/svelte-hmr/pull/78)) 14 | 15 | ## 0.15.2 16 | 17 | ### Patch Changes 18 | 19 | - Accept Svelte 4 as peer dependency ([`3b4300c`](https://github.com/sveltejs/svelte-hmr/commit/3b4300cc8acc734c34dbfafc495c06d5d4d17803)) 20 | 21 | ## 0.15.1 22 | 23 | ### Patch Changes 24 | 25 | - support 'external' as value for compileOptions.css ([#63](https://github.com/sveltejs/svelte-hmr/pull/63)) 26 | 27 | ## 0.15.0 28 | 29 | ### Minor Changes 30 | 31 | - Add partialAccept option to fix HMR support of ` 58 | ``` 59 | 60 | #### preserveAllLocalStateKey 61 | 62 | Type: `string`
63 | Default: `'@hmr:keep-all'` 64 | 65 | Force preservation of all local variables of this component. 66 | 67 | ```svelte 68 | 69 | 70 | 75 | ``` 76 | 77 | #### preserveLocalStateKey 78 | 79 | Type: `string`
80 | Default: `'@hmr:keep'` 81 | 82 | Force preservation of a given local variable in this component. 83 | 84 | ```svelte 85 | 93 | ``` 94 | 95 | #### optimistic 96 | 97 | Type: `bool`
98 | Default: `false` 99 | 100 | When `false`, runtime errors during component init (i.e. when your ` 166 | 167 |

{x}

168 | ``` 169 | 170 | If you replace `let x = 1` by `let x = 10` and save, the previous value of `x` will be preserved. That is, `x` will be 2 and not 10. The restoration of previous state happens _after_ the init code of the component has run, so the value will not be 11 either, despite the `x++` that is still here. 171 | 172 | If you want this behaviour for all the state of all your components, you can enable it by setting the `preserveLocalState` option to `true`. 173 | 174 | If you then want to disable it for just one particular file, or just temporarily, you can turn it off by adding a `// @hmr:reset` comment somewhere in your component. 175 | 176 | On the contrary, if you keep the default `preserveLocalState` to `false`, you can enable preservation of all the local state of a given component by adding the following comment: `// @hmr:keep-all`. You can also preserve only the state of some specific variables, by annotating them with: `// @hmr:keep`. 177 | 178 | For example: 179 | 180 | ```svelte 181 | 190 | ``` 191 | 192 | ## Svelte HMR tools 193 | 194 | ### Vite 195 | 196 | The [official Svelte plugin for Vite][vite-plugin-svelte] has HMR support. 197 | 198 | ### Webpack 199 | 200 | The [official loader for Webpack][svelte-loader] now has HMR support. 201 | 202 | ### Rollup 203 | 204 | Rollup does not natively support HMR, so we recommend using Vite. However, if you'd like to add HMR support to Rollup, the best way to get started is to refer to [svelte-template-hot], which demonstrates usage of both [rollup-plugin-hot] and [rollup-plugin-svelte-hot]. 205 | 206 | ### Svelte Native 207 | 208 | The official [Svelte Native template][svelte-native-template] already includes HMR support. 209 | 210 | ## License 211 | 212 | [ISC](LICENSE) 213 | 214 | [vite-plugin-svelte]: https://www.npmjs.com/package/@sveltejs/vite-plugin-svelte 215 | [svelte-loader]: https://github.com/sveltejs/svelte-loader 216 | [rollup-plugin-hot]: https://github.com/rixo/rollup-plugin-hot 217 | [rollup-plugin-svelte-hot]: https://github.com/rixo/rollup-plugin-svelte-hot 218 | [rollup-plugin-svelte]: https://github.com/rollup/rollup-plugin-svelte 219 | [svelte-template-hot]: https://github.com/rixo/svelte-template-hot 220 | [svelte-template]: https://github.com/sveltejs/template 221 | [svelte-native-template]: https://github.com/halfnelson/svelte-native-template 222 | [svelte-loader-hot]: https://github.com/rixo/svelte-loader-hot 223 | [svelte-template-webpack-hot]: https://github.com/rixo/svelte-template-webpack-hot 224 | -------------------------------------------------------------------------------- /packages/svelte-hmr/index.js: -------------------------------------------------------------------------------- 1 | const createMakeHotFactory = require('./lib/make-hot.js') 2 | const { resolve } = require('path') 3 | const { name, version } = require('./package.json') 4 | 5 | const resolveAbsoluteImport = target => resolve(__dirname, target) 6 | 7 | const createMakeHot = createMakeHotFactory({ 8 | pkg: { name, version }, 9 | resolveAbsoluteImport, 10 | }) 11 | 12 | module.exports = { createMakeHot } 13 | -------------------------------------------------------------------------------- /packages/svelte-hmr/lib/css-only.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Injects a `{}*` CSS rule to force Svelte compiler to scope every elements. 3 | */ 4 | export const injectScopeEverythingCssRule = (parse, code) => { 5 | const { css } = parse(code) 6 | if (!css) return code 7 | const { 8 | content: { end }, 9 | } = css 10 | return code.slice(0, end) + '*{}' + code.slice(end) 11 | } 12 | 13 | export const normalizeJsCode = code => { 14 | // Svelte now adds locations in dev mode, code locations can change when 15 | // CSS change, but we're unaffected (not real behaviour changes) 16 | code = code.replace(/\badd_location\s*\([^)]*\)\s*;?/g, '') 17 | return code 18 | } 19 | -------------------------------------------------------------------------------- /packages/svelte-hmr/lib/make-hot.js: -------------------------------------------------------------------------------- 1 | const globalName = '___SVELTE_HMR_HOT_API' 2 | const globalAdapterName = '___SVELTE_HMR_HOT_API_PROXY_ADAPTER' 3 | 4 | const defaultHotOptions = { 5 | // preserve all local state 6 | preserveLocalState: false, 7 | 8 | // escape hatchs from preservation of local state 9 | // 10 | // disable preservation of state for this component 11 | noPreserveStateKey: ['@hmr:reset', '@!hmr'], 12 | // enable preservation of state for all variables in this component 13 | preserveAllLocalStateKey: '@hmr:keep-all', 14 | // enable preservation of state for a given variable (must be inline or 15 | // above the target variable or variables; can be repeated) 16 | preserveLocalStateKey: '@hmr:keep', 17 | 18 | // don't reload on fatal error 19 | noReload: false, 20 | // try to recover after runtime errors during component init 21 | // defaults to false because some runtime errors are fatal and require a full reload 22 | optimistic: false, 23 | // auto accept modules of components that have named exports (i.e. exports 24 | // from context="module") 25 | acceptNamedExports: true, 26 | // auto accept modules of components have accessors (either accessors compile 27 | // option, or ) -- this means that if you 28 | // set accessors compile option globally, you must also set this option to 29 | // true, or no component will be hot reloaded (but there are a lot of edge 30 | // cases that HMR can't support correctly with accessors) 31 | acceptAccessors: true, 32 | // only inject CSS instead of recreating components when only CSS changes 33 | injectCss: false, 34 | // to mitigate FOUC between dispose (remove stylesheet) and accept 35 | cssEjectDelay: 100, 36 | 37 | // Svelte Native mode 38 | native: false, 39 | // name of the adapter import binding 40 | importAdapterName: globalAdapterName, 41 | 42 | // disable runtime error overlay 43 | noOverlay: false, 44 | 45 | // set to true on systems that supports them, to avoid useless caught / 46 | // managed (but still...) exceptions, by using Svelte's current_component 47 | // instead of get_current_component 48 | allowLiveBinding: false, 49 | 50 | // use acceptExports instead of accept, to fix support of context:module 51 | partialAccept: false, 52 | } 53 | 54 | const defaultHotApi = 'hot-api-esm.js' 55 | 56 | const json = JSON.stringify 57 | 58 | const posixify = file => file.replace(/[/\\]/g, '/') 59 | 60 | const renderApplyHmr = ({ 61 | id, 62 | cssId, 63 | nonCssHash, 64 | hotOptions: { 65 | injectCss, 66 | partialAccept, 67 | _accept = partialAccept ? "acceptExports(['default'])" : 'accept()', 68 | }, 69 | hotOptionsJson, 70 | hotApiImport, 71 | adapterImport, 72 | importAdapterName, 73 | meta, 74 | acceptable, 75 | preserveLocalState, 76 | emitCss, 77 | imports = [ 78 | `import * as ${globalName} from ${JSON.stringify(hotApiImport)}`, 79 | `import { adapter as ${importAdapterName} } from ${JSON.stringify( 80 | adapterImport 81 | )}`, 82 | ], 83 | }) => 84 | // this silly formatting keeps all original characters in their position, 85 | // thus saving us from having to provide a sourcemap 86 | // 87 | // NOTE the `if (false) accept()` line is for tools that wants to see the 88 | // accept call in the actual accepted module to enable HMR (Vite and, I 89 | // believe, Snowpack 3) 90 | // 91 | `${imports.join(';')};${` 92 | if (${meta} && ${meta}.hot) { 93 | ${acceptable ? `if (false) ${meta}.hot.${_accept};` : ''}; 94 | $2 = ${globalName}.applyHmr({ 95 | m: ${meta}, 96 | id: ${json(id)}, 97 | hotOptions: ${hotOptionsJson}, 98 | Component: $2, 99 | ProxyAdapter: ${importAdapterName}, 100 | acceptable: ${json(acceptable)}, 101 | preserveLocalState: ${json(preserveLocalState)}, 102 | ${cssId ? `cssId: ${json(cssId)},` : ''} 103 | ${nonCssHash ? `nonCssHash: ${json(nonCssHash)},` : ''} 104 | emitCss: ${json(emitCss)}, 105 | }); 106 | } 107 | ` 108 | .split('\n') 109 | .map(x => x.trim()) 110 | .filter(Boolean) 111 | .join(' ')} 112 | export default $2; 113 | ${ 114 | // NOTE when doing CSS only voodoo, we have to inject the stylesheet as soon 115 | // as the component is loaded because Svelte normally do that when a component 116 | // is instantiated, but we might already have instances in the large when a 117 | // component is loaded with HMR 118 | cssId && injectCss && !emitCss 119 | ? ` 120 | if (typeof add_css !== 'undefined' && !document.getElementById(${json( 121 | cssId 122 | )})) add_css();` 123 | : `` 124 | } 125 | ` 126 | 127 | // replace from last occurrence of the splitter 128 | const replaceLast = (string, splitter, regex, replacer) => { 129 | const lastIndex = string.lastIndexOf(splitter) 130 | return ( 131 | string.slice(0, lastIndex) + 132 | string.slice(lastIndex).replace(regex, replacer) 133 | ) 134 | } 135 | 136 | // https://github.com/darkskyapp/string-hash/blob/master/index.js 137 | // (via https://github.com/sveltejs/svelte/blob/91d758e35b2b2154512ddd11e6b6d9d65708a99e/src/compiler/compile/utils/hash.ts#L2) 138 | const stringHashcode = str => { 139 | let hash = 5381 140 | let i = str.length 141 | while (i--) hash = ((hash << 5) - hash) ^ str.charCodeAt(i) 142 | return (hash >>> 0).toString(36) 143 | } 144 | 145 | const normalizeJsCode = (code, cssHash) => 146 | code 147 | // ignore css hashes in the code (that have changed, necessarily) 148 | .replace(new RegExp('\\b' + cssHash + '\\b', 'g'), '') 149 | // Svelte now adds locations in dev mode, code locations can change when 150 | // CSS change, but we're unaffected (not real behaviour changes) 151 | .replace(/\badd_location\s*\([^)]*\)\s*;?/g, '') 152 | 153 | const parseCssId = (code, compileOptions = {}, parseHash, originalCode) => { 154 | // the regex matching is very pretty conservative 'cause I don't want to 155 | // match something else by error... I'm probably make it more lax if I have 156 | // to fix it 3 times in a single week ¯\_(ツ)_/¯ 157 | let match = /^function add_css\(\) \{[\s\S]*?^}/m.exec(code) 158 | 159 | if (!match) { 160 | // guard: injectCss is off, no need to compute hashes 161 | if (!parseHash) return {} 162 | // guard: compile.css is true, so we should have found the add_css function, 163 | // something unexpected is unraveling here, fall back to caution 164 | if (compileOptions.css) return {} 165 | // trying to get CSS id the same way as Svelte does it 166 | match = /]*>([\s\S]*)<\/\s*style\s*>/.exec(originalCode) 167 | const cssHash = match && match[1] ? stringHashcode(match[1]) : null 168 | if (!cssHash) return {} 169 | return { 170 | cssId: `svelte-${cssHash}-style`, 171 | nonCssHash: stringHashcode(normalizeJsCode(code, cssHash)), 172 | } 173 | } 174 | 175 | const codeExceptCSS = 176 | code.slice(0, match.index) + code.slice(match.index + match[0].length) 177 | 178 | match = /\bstyle\.id\s*=\s*(['"])([^'"]*)\1/.exec(match[0]) 179 | const cssId = match ? match[2] : null 180 | 181 | if (!parseHash || !cssId) return { cssId } 182 | 183 | const cssHash = cssId.split('-')[1] 184 | const nonCssHash = stringHashcode(normalizeJsCode(codeExceptCSS, cssHash)) 185 | 186 | return { cssId, nonCssHash } 187 | } 188 | 189 | const isNamedExport = v => v.export_name && v.module 190 | 191 | const isProp = v => v.export_name && !v.module 192 | 193 | const resolvePackageImport = (name = 'svelte-hmr', version = '') => { 194 | const versionSpec = version ? '@' + version : '' 195 | return target => `${name}${versionSpec}/${target}` 196 | } 197 | 198 | const createMakeHot = ({ resolveAbsoluteImport, pkg = {} }) => ({ 199 | // NOTE hotOptions can be customized by end user through plugin options, while 200 | // options passed to this function can only customized by the plugin implementer 201 | // 202 | // meta can be 'import.meta' or 'module' 203 | // const createMakeHot = (hotApi = defaultHotApi, options) => { 204 | walk, 205 | meta = 'import.meta', 206 | 207 | hotApi: customHotApi = '', 208 | adapter: customAdapter = '', 209 | // use absolute file paths to import runtime deps of svelte-hmr 210 | // - pnpm needs absolute paths because svelte-hmr, being a transitive dep via 211 | // the bundler plugin, is not directly importable from user's project 212 | // (see https://github.com/rixo/svelte-hmr/issues/11) 213 | // - Snowpack source=remote needs a version number, otherwise it will try to 214 | // load import, resulting in a possible version mismatch between the code 215 | // transform and the runtime 216 | // (see: https://github.com/rixo/svelte-hmr/issues/27#issuecomment-800142127) 217 | absoluteImports = true, 218 | versionNonAbsoluteImports = true, 219 | 220 | hotOptions: hotOptionsArg = {}, 221 | }) => { 222 | const hotOptions = { ...defaultHotOptions, ...hotOptionsArg } 223 | 224 | const hotOptionsJson = JSON.stringify(hotOptions) 225 | 226 | // NOTE Native adapter cannot be required in code (as opposed to this 227 | // generated code) because it requires modules from NativeScript's code that 228 | // are not resolvable for non-native users (and those missing modules would 229 | // prevent webpack from building). 230 | // 231 | // careful with relative paths 232 | // (see https://github.com/rixo/svelte-hmr/issues/11) 233 | const defaultAdapter = hotOptions.native 234 | ? 'svelte-native/proxy-adapter-native.js' 235 | : 'proxy-adapter-dom.js' 236 | 237 | const resolveImport = absoluteImports 238 | ? resolveAbsoluteImport 239 | : resolvePackageImport(pkg.name, versionNonAbsoluteImports && pkg.version) 240 | 241 | const adapterImport = posixify( 242 | customAdapter || resolveImport('runtime/' + defaultAdapter) 243 | ) 244 | 245 | const hotApiImport = posixify( 246 | customHotApi || resolveImport('runtime/' + defaultHotApi) 247 | ) 248 | 249 | const resolvePreserveLocalStateKey = ({ 250 | preserveLocalStateKey, 251 | compiled, 252 | }) => { 253 | const containsKey = comments => 254 | comments && 255 | comments.some(({ value }) => value.includes(preserveLocalStateKey)) 256 | 257 | const variables = new Set() 258 | 259 | const addReference = node => { 260 | if (!node.name) { 261 | // eslint-disable-next-line no-console 262 | console.warn('Incorrect identifier for preserveLocalStateKey') 263 | } 264 | variables.add(node.name) 265 | } 266 | 267 | const processNodes = targets => targets.forEach(processNode) 268 | 269 | const processNode = node => { 270 | switch (node.type) { 271 | case 'Identifier': 272 | variables.add(node.name) 273 | return true 274 | case 'UpdateExpression': 275 | addReference(node.argument) 276 | return true 277 | case 'VariableDeclarator': 278 | addReference(node.id) 279 | return true 280 | case 'AssignmentExpression': 281 | processNode(node.left) 282 | return true 283 | case 'ExpressionStatement': 284 | processNode(node.expression) 285 | return true 286 | 287 | case 'VariableDeclaration': 288 | processNodes(node.declarations) 289 | return true 290 | case 'SequenceExpression': // ++, -- 291 | processNodes(node.expressions) 292 | return true 293 | } 294 | return false 295 | } 296 | 297 | const stack = [] 298 | 299 | if (compiled.ast.instance) { 300 | walk(compiled.ast.instance, { 301 | leave() { 302 | stack.shift() 303 | }, 304 | enter(node) { 305 | stack.unshift(node) 306 | if ( 307 | containsKey(node.leadingComments) || 308 | containsKey(node.trailingComments) 309 | ) { 310 | stack 311 | .slice(0, 3) 312 | .reverse() 313 | .some(processNode) 314 | } 315 | }, 316 | }) 317 | } 318 | 319 | return [...variables] 320 | } 321 | 322 | const resolvePreserveLocalState = ({ 323 | hotOptions, 324 | originalCode, 325 | compiled, 326 | }) => { 327 | const { 328 | preserveLocalState, 329 | noPreserveStateKey, 330 | preserveLocalStateKey, 331 | preserveAllLocalStateKey, 332 | } = hotOptions 333 | if (originalCode) { 334 | const hasKey = key => { 335 | const test = k => originalCode.indexOf(k) !== -1 336 | return Array.isArray(key) ? key.some(test) : test(key) 337 | } 338 | // noPreserveStateKey 339 | if (noPreserveStateKey && hasKey(noPreserveStateKey)) { 340 | return false 341 | } 342 | // preserveAllLocalStateKey 343 | if (preserveAllLocalStateKey && hasKey(preserveAllLocalStateKey)) { 344 | return true 345 | } 346 | // preserveLocalStateKey 347 | if (preserveLocalStateKey && hasKey(preserveLocalStateKey)) { 348 | // returns an array of variable names to preserve 349 | return resolvePreserveLocalStateKey({ preserveLocalStateKey, compiled }) 350 | } 351 | } 352 | // preserveLocalState 353 | if (preserveLocalState) { 354 | return true 355 | } 356 | return false 357 | } 358 | 359 | const hasAccessorsOption = compiled => { 360 | if (!compiled.ast || !compiled.ast.html) return 361 | let accessors = false 362 | walk(compiled.ast.html, { 363 | enter(node) { 364 | if (accessors) return 365 | if (node.type !== 'Options') return 366 | if (!node.attributes) return 367 | accessors = node.attributes.some( 368 | ({ name, value }) => name === 'accessors' && value 369 | ) 370 | }, 371 | }) 372 | return accessors 373 | } 374 | 375 | const isAcceptable = (hotOptions, compileOptions, compiled) => { 376 | if (!compiled || !compileOptions) { 377 | // this should never happen, since it's the bundler plugins that control 378 | // what version of svelte-hmr they embark, and they should be kept up to 379 | // date with our API 380 | // 381 | // eslint-disable-next-line no-console 382 | console.warn( 383 | 'WARNING Your bundler plugin is outdated for this version of svelte-hmr' 384 | ) 385 | return true 386 | } 387 | 388 | const { vars } = compiled 389 | 390 | // if the module has named exports (in context="module"), then we can't 391 | // auto accept the component, since all the consumers need to be aware of 392 | // the change (e.g. rerender with the new exports value) 393 | if (!hotOptions.acceptNamedExports && vars.some(isNamedExport)) { 394 | return false 395 | } 396 | 397 | // ...same for accessors: if accessible props change, then we need to 398 | // rerender/rerun all the consumers to reflect the change 399 | // 400 | // NOTE we can still accept components with no props, since they won't 401 | // have accessors... this is actually all we can safely accept in this case 402 | // 403 | if ( 404 | !hotOptions.acceptAccessors && 405 | // we test is we have props first, because searching for the 406 | // tag in the AST is probably the most expensive here 407 | vars.some(isProp) && 408 | (compileOptions.accessors || hasAccessorsOption(compiled)) 409 | ) { 410 | return false 411 | } 412 | 413 | return true 414 | } 415 | 416 | const parseMakeHotArgs = args => { 417 | // case: named args (object) 418 | if (args.length === 1) return args[0] 419 | // case: legacy (positional) 420 | const [ 421 | id, 422 | compiledCode, 423 | hotOptions, 424 | compiled, 425 | originalCode, 426 | compileOptions, 427 | ] = args 428 | return { 429 | id, 430 | compiledCode, 431 | hotOptions, 432 | compiled, 433 | originalCode, 434 | compileOptions, 435 | } 436 | } 437 | 438 | function makeHot(...args) { 439 | const { 440 | id, 441 | compiledCode, 442 | compiled, 443 | originalCode, 444 | compileOptions, 445 | } = parseMakeHotArgs(args) 446 | 447 | const { importAdapterName, injectCss } = hotOptions 448 | 449 | const emitCss = 450 | compileOptions && 451 | (compileOptions.css === false || compileOptions.css === 'external') 452 | 453 | const preserveLocalState = resolvePreserveLocalState({ 454 | hotOptions, 455 | originalCode, 456 | compiled, 457 | }) 458 | 459 | const replacement = renderApplyHmr({ 460 | id, 461 | // adds cssId & nonCssHash 462 | ...((injectCss || !emitCss) && 463 | parseCssId(compiledCode, compileOptions, injectCss, originalCode)), 464 | hotOptions, 465 | hotOptionsJson, 466 | preserveLocalState, 467 | hotApiImport, 468 | adapterImport, 469 | importAdapterName, 470 | meta, 471 | acceptable: isAcceptable(hotOptions, compileOptions, compiled), 472 | // CSS is handled outside of Svelte: don't tamper with it! 473 | emitCss, 474 | }) 475 | 476 | // NOTE export default can appear in strings in use code 477 | // see: https://github.com/rixo/svelte-hmr/issues/34 478 | return replaceLast( 479 | compiledCode, 480 | 'export default', 481 | /(\n?export default ([^;]*);)/, 482 | (match, $1, $2) => replacement.replace(/\$2/g, () => $2) 483 | ) 484 | } 485 | 486 | // rollup-plugin-svelte-hot needs hotApi path (for tests) 487 | // makeHot.hotApi = hotApi 488 | Object.defineProperty(makeHot, 'hotApi', { 489 | get() { 490 | // TODO makeHot.hotApi has been lost in 0.12 => 0.13... still needed? 491 | debugger // eslint-disable-line no-debugger 492 | return undefined 493 | }, 494 | }) 495 | 496 | return makeHot 497 | } 498 | 499 | module.exports = createMakeHot 500 | -------------------------------------------------------------------------------- /packages/svelte-hmr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-hmr", 3 | "version": "0.16.0", 4 | "description": "Bundler agnostic HMR utils for Svelte 3", 5 | "main": "index.js", 6 | "author": "rixo ", 7 | "license": "ISC", 8 | "homepage": "https://github.com/sveltejs/svelte-hmr", 9 | "bugs": { 10 | "url": "https://github.com/sveltejs/svelte-hmr/issues" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/sveltejs/svelte-hmr", 15 | "directory": "packages/svelte-hmr" 16 | }, 17 | "files": [ 18 | "index.js", 19 | "lib", 20 | "runtime" 21 | ], 22 | "engines": { 23 | "node": "^14.13.1 || >= 16" 24 | }, 25 | "peerDependencies": { 26 | "svelte": "^3.54.0 || ^4.0.0" 27 | }, 28 | "scripts": { 29 | "lint": "eslint '**/*.{js,cjs,mjs}'", 30 | "lint:fix": "pnpm run lint --fix", 31 | "format": "prettier '**/*.{js,cjs,mjs}' --check", 32 | "format:fix": "pnpm run format --write" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/svelte-hmr/runtime/hot-api-esm.js: -------------------------------------------------------------------------------- 1 | import { makeApplyHmr } from '../runtime/index.js' 2 | 3 | export const applyHmr = makeApplyHmr(args => 4 | Object.assign({}, args, { 5 | hot: args.m.hot, 6 | }) 7 | ) 8 | -------------------------------------------------------------------------------- /packages/svelte-hmr/runtime/hot-api.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | import { createProxy, hasFatalError } from './proxy.js' 4 | 5 | const logPrefix = '[HMR:Svelte]' 6 | 7 | // eslint-disable-next-line no-console 8 | const log = (...args) => console.log(logPrefix, ...args) 9 | 10 | const domReload = () => { 11 | // eslint-disable-next-line no-undef 12 | const win = typeof window !== 'undefined' && window 13 | if (win && win.location && win.location.reload) { 14 | log('Reload') 15 | win.location.reload() 16 | } else { 17 | log('Full reload required') 18 | } 19 | } 20 | 21 | const replaceCss = (previousId, newId) => { 22 | if (typeof document === 'undefined') return false 23 | if (!previousId) return false 24 | if (!newId) return false 25 | // svelte-xxx-style => svelte-xxx 26 | const previousClass = previousId.slice(0, -6) 27 | const newClass = newId.slice(0, -6) 28 | // eslint-disable-next-line no-undef 29 | document.querySelectorAll('.' + previousClass).forEach(el => { 30 | el.classList.remove(previousClass) 31 | el.classList.add(newClass) 32 | }) 33 | return true 34 | } 35 | 36 | const removeStylesheet = cssId => { 37 | if (cssId == null) return 38 | if (typeof document === 'undefined') return 39 | // eslint-disable-next-line no-undef 40 | const el = document.getElementById(cssId) 41 | if (el) el.remove() 42 | return 43 | } 44 | 45 | const defaultArgs = { 46 | reload: domReload, 47 | } 48 | 49 | export const makeApplyHmr = transformArgs => args => { 50 | const allArgs = transformArgs({ ...defaultArgs, ...args }) 51 | return applyHmr(allArgs) 52 | } 53 | 54 | let needsReload = false 55 | 56 | function applyHmr(args) { 57 | const { 58 | id, 59 | cssId, 60 | nonCssHash, 61 | reload = domReload, 62 | // normalized hot API (must conform to rollup-plugin-hot) 63 | hot, 64 | hotOptions, 65 | Component, 66 | acceptable, // some types of components are impossible to HMR correctly 67 | preserveLocalState, 68 | ProxyAdapter, 69 | emitCss, 70 | } = args 71 | 72 | const existing = hot.data && hot.data.record 73 | 74 | const canAccept = acceptable && (!existing || existing.current.canAccept) 75 | 76 | const r = 77 | existing || 78 | createProxy({ 79 | Adapter: ProxyAdapter, 80 | id, 81 | Component, 82 | hotOptions, 83 | canAccept, 84 | preserveLocalState, 85 | }) 86 | 87 | const cssOnly = 88 | hotOptions.injectCss && 89 | existing && 90 | nonCssHash && 91 | existing.current.nonCssHash === nonCssHash 92 | 93 | r.update({ 94 | Component, 95 | hotOptions, 96 | canAccept, 97 | nonCssHash, 98 | cssId, 99 | previousCssId: r.current.cssId, 100 | cssOnly, 101 | preserveLocalState, 102 | }) 103 | 104 | hot.dispose(data => { 105 | // handle previous fatal errors 106 | if (needsReload || hasFatalError()) { 107 | if (hotOptions && hotOptions.noReload) { 108 | log('Full reload required') 109 | } else { 110 | reload() 111 | } 112 | } 113 | 114 | // 2020-09-21 Snowpack master doesn't pass data as arg to dispose handler 115 | data = data || hot.data 116 | 117 | data.record = r 118 | 119 | if (!emitCss && cssId && r.current.cssId !== cssId) { 120 | if (hotOptions.cssEjectDelay) { 121 | setTimeout(() => removeStylesheet(cssId), hotOptions.cssEjectDelay) 122 | } else { 123 | removeStylesheet(cssId) 124 | } 125 | } 126 | }) 127 | 128 | if (canAccept) { 129 | hot.accept(async arg => { 130 | const { bubbled } = arg || {} 131 | 132 | // NOTE Snowpack registers accept handlers only once, so we can NOT rely 133 | // on the surrounding scope variables -- they're not the last version! 134 | const { cssId: newCssId, previousCssId } = r.current 135 | const cssChanged = newCssId !== previousCssId 136 | // ensure old style sheet has been removed by now 137 | if (!emitCss && cssChanged) removeStylesheet(previousCssId) 138 | // guard: css only change 139 | if ( 140 | // NOTE bubbled is provided only by rollup-plugin-hot, and we 141 | // can't safely assume a CSS only change without it... this means we 142 | // can't support CSS only injection with Nollup or Webpack currently 143 | bubbled === false && // WARNING check false, not falsy! 144 | r.current.cssOnly && 145 | (!cssChanged || replaceCss(previousCssId, newCssId)) 146 | ) { 147 | return 148 | } 149 | 150 | const success = await r.reload() 151 | 152 | if (hasFatalError() || (!success && !hotOptions.optimistic)) { 153 | needsReload = true 154 | } 155 | }) 156 | } 157 | 158 | // well, endgame... we won't be able to render next updates, even successful, 159 | // if we don't have proxies in svelte's tree 160 | // 161 | // since we won't return the proxy and the app will expect a svelte component, 162 | // it's gonna crash... so it's best to report the real cause 163 | // 164 | // full reload required 165 | // 166 | const proxyOk = r && r.proxy 167 | if (!proxyOk) { 168 | throw new Error(`Failed to create HMR proxy for Svelte component ${id}`) 169 | } 170 | 171 | return r.proxy 172 | } 173 | -------------------------------------------------------------------------------- /packages/svelte-hmr/runtime/index.js: -------------------------------------------------------------------------------- 1 | export { makeApplyHmr } from './hot-api.js' 2 | -------------------------------------------------------------------------------- /packages/svelte-hmr/runtime/overlay.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | const removeElement = el => el && el.parentNode && el.parentNode.removeChild(el) 4 | 5 | const ErrorOverlay = () => { 6 | let errors = [] 7 | let compileError = null 8 | 9 | const errorsTitle = 'Failed to init component' 10 | const compileErrorTitle = 'Failed to compile' 11 | 12 | const style = { 13 | section: ` 14 | position: fixed; 15 | top: 0; 16 | bottom: 0; 17 | left: 0; 18 | right: 0; 19 | padding: 32px; 20 | background: rgba(0, 0, 0, .85); 21 | font-family: Menlo, Consolas, monospace; 22 | font-size: large; 23 | color: rgb(232, 232, 232); 24 | overflow: auto; 25 | z-index: 2147483647; 26 | `, 27 | h1: ` 28 | margin-top: 0; 29 | color: #E36049; 30 | font-size: large; 31 | font-weight: normal; 32 | `, 33 | h2: ` 34 | margin: 32px 0 0; 35 | font-size: large; 36 | font-weight: normal; 37 | `, 38 | pre: ``, 39 | } 40 | 41 | const createOverlay = () => { 42 | const h1 = document.createElement('h1') 43 | h1.style = style.h1 44 | const section = document.createElement('section') 45 | section.appendChild(h1) 46 | section.style = style.section 47 | const body = document.createElement('div') 48 | section.appendChild(body) 49 | return { h1, el: section, body } 50 | } 51 | 52 | const setTitle = title => { 53 | overlay.h1.textContent = title 54 | } 55 | 56 | const show = () => { 57 | const { el } = overlay 58 | if (!el.parentNode) { 59 | const target = document.body 60 | target.appendChild(overlay.el) 61 | } 62 | } 63 | 64 | const hide = () => { 65 | const { el } = overlay 66 | if (el.parentNode) { 67 | overlay.el.remove() 68 | } 69 | } 70 | 71 | const update = () => { 72 | if (compileError) { 73 | overlay.body.innerHTML = '' 74 | setTitle(compileErrorTitle) 75 | const errorEl = renderError(compileError) 76 | overlay.body.appendChild(errorEl) 77 | show() 78 | } else if (errors.length > 0) { 79 | overlay.body.innerHTML = '' 80 | setTitle(errorsTitle) 81 | errors.forEach(({ title, message }) => { 82 | const errorEl = renderError(message, title) 83 | overlay.body.appendChild(errorEl) 84 | }) 85 | show() 86 | } else { 87 | hide() 88 | } 89 | } 90 | 91 | const renderError = (message, title) => { 92 | const div = document.createElement('div') 93 | if (title) { 94 | const h2 = document.createElement('h2') 95 | h2.textContent = title 96 | h2.style = style.h2 97 | div.appendChild(h2) 98 | } 99 | const pre = document.createElement('pre') 100 | pre.textContent = message 101 | div.appendChild(pre) 102 | return div 103 | } 104 | 105 | const addError = (error, title) => { 106 | const message = (error && error.stack) || error 107 | errors.push({ title, message }) 108 | update() 109 | } 110 | 111 | const clearErrors = () => { 112 | errors.forEach(({ element }) => { 113 | removeElement(element) 114 | }) 115 | errors = [] 116 | update() 117 | } 118 | 119 | const setCompileError = message => { 120 | compileError = message 121 | update() 122 | } 123 | 124 | const overlay = createOverlay() 125 | 126 | return { 127 | addError, 128 | clearErrors, 129 | setCompileError, 130 | } 131 | } 132 | 133 | export default ErrorOverlay 134 | -------------------------------------------------------------------------------- /packages/svelte-hmr/runtime/proxy-adapter-dom.js: -------------------------------------------------------------------------------- 1 | /* global window, document */ 2 | import * as svelteInternal from 'svelte/internal' 3 | // NOTE from 3.38.3 (or so), insert was carrying the hydration logic, that must 4 | // be used because DOM elements are reused more (and so insertion points are not 5 | // necessarily added in order); then in 3.40 the logic was moved to 6 | // insert_hydration, which is the one we must use for HMR 7 | const svelteInsert = svelteInternal.insert_hydration || svelteInternal.insert 8 | if (!svelteInsert) { 9 | throw new Error( 10 | 'failed to find insert_hydration and insert in svelte/internal' 11 | ) 12 | } 13 | 14 | import ErrorOverlay from './overlay.js' 15 | 16 | const removeElement = el => el && el.parentNode && el.parentNode.removeChild(el) 17 | 18 | export const adapter = class ProxyAdapterDom { 19 | constructor(instance) { 20 | this.instance = instance 21 | this.insertionPoint = null 22 | 23 | this.afterMount = this.afterMount.bind(this) 24 | this.rerender = this.rerender.bind(this) 25 | 26 | this._noOverlay = !!instance.hotOptions.noOverlay 27 | } 28 | 29 | // NOTE overlay is only created before being actually shown to help test 30 | // runner (it won't have to account for error overlay when running assertions 31 | // about the contents of the rendered page) 32 | static getErrorOverlay(noCreate = false) { 33 | if (!noCreate && !this.errorOverlay) { 34 | this.errorOverlay = ErrorOverlay() 35 | } 36 | return this.errorOverlay 37 | } 38 | 39 | // TODO this is probably unused now: remove in next breaking release 40 | static renderCompileError(message) { 41 | const noCreate = !message 42 | const overlay = this.getErrorOverlay(noCreate) 43 | if (!overlay) return 44 | overlay.setCompileError(message) 45 | } 46 | 47 | dispose() { 48 | // Component is being destroyed, detaching is not optional in Svelte3's 49 | // component API, so we can dispose of the insertion point in every case. 50 | if (this.insertionPoint) { 51 | removeElement(this.insertionPoint) 52 | this.insertionPoint = null 53 | } 54 | this.clearError() 55 | } 56 | 57 | // NOTE afterMount CAN be called multiple times (e.g. keyed list) 58 | afterMount(target, anchor) { 59 | const { 60 | instance: { debugName }, 61 | } = this 62 | if (!this.insertionPoint) { 63 | this.insertionPoint = document.createComment(debugName) 64 | } 65 | svelteInsert(target, this.insertionPoint, anchor) 66 | } 67 | 68 | rerender() { 69 | this.clearError() 70 | const { 71 | instance: { refreshComponent }, 72 | insertionPoint, 73 | } = this 74 | if (!insertionPoint) { 75 | throw new Error('Cannot rerender: missing insertion point') 76 | } 77 | refreshComponent(insertionPoint.parentNode, insertionPoint) 78 | } 79 | 80 | renderError(err) { 81 | if (this._noOverlay) return 82 | const { 83 | instance: { debugName }, 84 | } = this 85 | const title = debugName || err.moduleName || 'Error' 86 | this.constructor.getErrorOverlay().addError(err, title) 87 | } 88 | 89 | clearError() { 90 | if (this._noOverlay) return 91 | const overlay = this.constructor.getErrorOverlay(true) 92 | if (!overlay) return 93 | overlay.clearErrors() 94 | } 95 | } 96 | 97 | // TODO this is probably unused now: remove in next breaking release 98 | if (typeof window !== 'undefined') { 99 | window.__SVELTE_HMR_ADAPTER = adapter 100 | } 101 | 102 | // mitigate situation with Snowpack remote source pulling latest of runtime, 103 | // but using previous version of the Node code transform in the plugin 104 | // see: https://github.com/rixo/svelte-hmr/issues/27 105 | export default adapter 106 | -------------------------------------------------------------------------------- /packages/svelte-hmr/runtime/proxy.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | /** 3 | * The HMR proxy is a component-like object whose task is to sit in the 4 | * component tree in place of the proxied component, and rerender each 5 | * successive versions of said component. 6 | */ 7 | 8 | import { createProxiedComponent } from './svelte-hooks.js' 9 | 10 | const handledMethods = ['constructor', '$destroy'] 11 | const forwardedMethods = ['$set', '$on'] 12 | 13 | const logError = (msg, err) => { 14 | // eslint-disable-next-line no-console 15 | console.error('[HMR][Svelte]', msg) 16 | if (err) { 17 | // NOTE avoid too much wrapping around user errors 18 | // eslint-disable-next-line no-console 19 | console.error(err) 20 | } 21 | } 22 | 23 | const posixify = file => file.replace(/[/\\]/g, '/') 24 | 25 | const getBaseName = id => 26 | id 27 | .split('/') 28 | .pop() 29 | .split('.') 30 | .slice(0, -1) 31 | .join('.') 32 | 33 | const capitalize = str => str[0].toUpperCase() + str.slice(1) 34 | 35 | const getFriendlyName = id => capitalize(getBaseName(posixify(id))) 36 | 37 | const getDebugName = id => `<${getFriendlyName(id)}>` 38 | 39 | const relayCalls = (getTarget, names, dest = {}) => { 40 | for (const key of names) { 41 | dest[key] = function(...args) { 42 | const target = getTarget() 43 | if (!target) { 44 | return 45 | } 46 | return target[key] && target[key].call(this, ...args) 47 | } 48 | } 49 | return dest 50 | } 51 | 52 | const isInternal = key => key !== '$$' && key.slice(0, 2) === '$$' 53 | 54 | // This is intented as a somewhat generic / prospective fix to the situation 55 | // that arised with the introduction of $$set in Svelte 3.24.1 -- trying to 56 | // avoid giving full knowledge (like its name) of this implementation detail 57 | // to the proxy. The $$set method can be present or not on the component, and 58 | // its presence impacts the behaviour (but with HMR it will be tested if it is 59 | // present _on the proxy_). So the idea here is to expose exactly the same $$ 60 | // props as the current version of the component and, for those that are 61 | // functions, proxy the calls to the current component. 62 | const relayInternalMethods = (proxy, cmp) => { 63 | // delete any previously added $$ prop 64 | Object.keys(proxy) 65 | .filter(isInternal) 66 | .forEach(key => { 67 | delete proxy[key] 68 | }) 69 | // guard: no component 70 | if (!cmp) return 71 | // proxy current $$ props to the actual component 72 | Object.keys(cmp) 73 | .filter(isInternal) 74 | .forEach(key => { 75 | Object.defineProperty(proxy, key, { 76 | configurable: true, 77 | get() { 78 | const value = cmp[key] 79 | if (typeof value !== 'function') return value 80 | return ( 81 | value && 82 | function(...args) { 83 | return value.apply(this, args) 84 | } 85 | ) 86 | }, 87 | }) 88 | }) 89 | } 90 | 91 | // proxy custom methods 92 | const copyComponentProperties = (proxy, cmp, previous) => { 93 | if (previous) { 94 | previous.forEach(prop => { 95 | delete proxy[prop] 96 | }) 97 | } 98 | 99 | const props = Object.getOwnPropertyNames(Object.getPrototypeOf(cmp)) 100 | const wrappedProps = props.filter(prop => { 101 | if (!handledMethods.includes(prop) && !forwardedMethods.includes(prop)) { 102 | Object.defineProperty(proxy, prop, { 103 | configurable: true, 104 | get() { 105 | return cmp[prop] 106 | }, 107 | set(value) { 108 | // we're changing it on the real component first to see what it 109 | // gives... if it throws an error, we want to throw the same error in 110 | // order to most closely follow non-hmr behaviour. 111 | cmp[prop] = value 112 | }, 113 | }) 114 | return true 115 | } 116 | }) 117 | 118 | return wrappedProps 119 | } 120 | 121 | // everything in the constructor! 122 | // 123 | // so we don't polute the component class with new members 124 | // 125 | class ProxyComponent { 126 | constructor( 127 | { 128 | Adapter, 129 | id, 130 | debugName, 131 | current, // { Component, hotOptions: { preserveLocalState, ... } } 132 | register, 133 | }, 134 | options // { target, anchor, ... } 135 | ) { 136 | let cmp 137 | let disposed = false 138 | let lastError = null 139 | 140 | const setComponent = _cmp => { 141 | cmp = _cmp 142 | relayInternalMethods(this, cmp) 143 | } 144 | 145 | const getComponent = () => cmp 146 | 147 | const destroyComponent = () => { 148 | // destroyComponent is tolerant (don't crash on no cmp) because it 149 | // is possible that reload/rerender is called after a previous 150 | // createComponent has failed (hence we have a proxy, but no cmp) 151 | if (cmp) { 152 | cmp.$destroy() 153 | setComponent(null) 154 | } 155 | } 156 | 157 | const refreshComponent = (target, anchor, conservativeDestroy) => { 158 | if (lastError) { 159 | lastError = null 160 | adapter.rerender() 161 | } else { 162 | try { 163 | const replaceOptions = { 164 | target, 165 | anchor, 166 | preserveLocalState: current.preserveLocalState, 167 | } 168 | if (conservativeDestroy) { 169 | replaceOptions.conservativeDestroy = true 170 | } 171 | cmp.$replace(current.Component, replaceOptions) 172 | } catch (err) { 173 | setError(err, target, anchor) 174 | if ( 175 | !current.hotOptions.optimistic || 176 | // non acceptable components (that is components that have to defer 177 | // to their parent for rerender -- e.g. accessors, named exports) 178 | // are most tricky, and they havent been considered when most of the 179 | // code has been written... as a result, they are especially tricky 180 | // to deal with, it's better to consider any error with them to be 181 | // fatal to avoid odities 182 | !current.canAccept || 183 | (err && err.hmrFatal) 184 | ) { 185 | throw err 186 | } else { 187 | // const errString = String((err && err.stack) || err) 188 | logError(`Error during component init: ${debugName}`, err) 189 | } 190 | } 191 | } 192 | } 193 | 194 | const setError = err => { 195 | lastError = err 196 | adapter.renderError(err) 197 | } 198 | 199 | const instance = { 200 | hotOptions: current.hotOptions, 201 | proxy: this, 202 | id, 203 | debugName, 204 | refreshComponent, 205 | } 206 | 207 | const adapter = new Adapter(instance) 208 | 209 | const { afterMount, rerender } = adapter 210 | 211 | // $destroy is not called when a child component is disposed, so we 212 | // need to hook from fragment. 213 | const onDestroy = () => { 214 | // NOTE do NOT call $destroy on the cmp from here; the cmp is already 215 | // dead, this would not work 216 | if (!disposed) { 217 | disposed = true 218 | adapter.dispose() 219 | unregister() 220 | } 221 | } 222 | 223 | // ---- register proxy instance ---- 224 | 225 | const unregister = register(rerender) 226 | 227 | // ---- augmented methods ---- 228 | 229 | this.$destroy = () => { 230 | destroyComponent() 231 | onDestroy() 232 | } 233 | 234 | // ---- forwarded methods ---- 235 | 236 | relayCalls(getComponent, forwardedMethods, this) 237 | 238 | // ---- create & mount target component instance --- 239 | 240 | try { 241 | let lastProperties 242 | createProxiedComponent(current.Component, options, { 243 | allowLiveBinding: current.hotOptions.allowLiveBinding, 244 | onDestroy, 245 | onMount: afterMount, 246 | onInstance: comp => { 247 | setComponent(comp) 248 | // WARNING the proxy MUST use the same $$ object as its component 249 | // instance, because a lot of wiring happens during component 250 | // initialisation... lots of references to $$ and $$.fragment have 251 | // already been distributed around when the component constructor 252 | // returns, before we have a chance to wrap them (and so we can't 253 | // wrap them no more, because existing references would become 254 | // invalid) 255 | this.$$ = comp.$$ 256 | lastProperties = copyComponentProperties(this, comp, lastProperties) 257 | }, 258 | }) 259 | } catch (err) { 260 | const { target, anchor } = options 261 | setError(err, target, anchor) 262 | throw err 263 | } 264 | } 265 | } 266 | 267 | const syncStatics = (component, proxy, previousKeys) => { 268 | // remove previously copied keys 269 | if (previousKeys) { 270 | for (const key of previousKeys) { 271 | delete proxy[key] 272 | } 273 | } 274 | 275 | // forward static properties and methods 276 | const keys = [] 277 | for (const key in component) { 278 | keys.push(key) 279 | proxy[key] = component[key] 280 | } 281 | 282 | return keys 283 | } 284 | 285 | const globalListeners = {} 286 | 287 | const onGlobal = (event, fn) => { 288 | event = event.toLowerCase() 289 | if (!globalListeners[event]) globalListeners[event] = [] 290 | globalListeners[event].push(fn) 291 | } 292 | 293 | const fireGlobal = (event, ...args) => { 294 | const listeners = globalListeners[event] 295 | if (!listeners) return 296 | for (const fn of listeners) { 297 | fn(...args) 298 | } 299 | } 300 | 301 | const fireBeforeUpdate = () => fireGlobal('beforeupdate') 302 | 303 | const fireAfterUpdate = () => fireGlobal('afterupdate') 304 | 305 | if (typeof window !== 'undefined') { 306 | window.__SVELTE_HMR = { 307 | on: onGlobal, 308 | } 309 | window.dispatchEvent(new CustomEvent('svelte-hmr:ready')) 310 | } 311 | 312 | let fatalError = false 313 | 314 | export const hasFatalError = () => fatalError 315 | 316 | /** 317 | * Creates a HMR proxy and its associated `reload` function that pushes a new 318 | * version to all existing instances of the component. 319 | */ 320 | export function createProxy({ 321 | Adapter, 322 | id, 323 | Component, 324 | hotOptions, 325 | canAccept, 326 | preserveLocalState, 327 | }) { 328 | const debugName = getDebugName(id) 329 | const instances = [] 330 | 331 | // current object will be updated, proxy instances will keep a ref 332 | const current = { 333 | Component, 334 | hotOptions, 335 | canAccept, 336 | preserveLocalState, 337 | } 338 | 339 | const name = `Proxy${debugName}` 340 | 341 | // this trick gives the dynamic name Proxy to the concrete 342 | // proxy class... unfortunately, this doesn't shows in dev tools, but 343 | // it stills allow to inspect cmp.constructor.name to confirm an instance 344 | // is a proxy 345 | const proxy = { 346 | [name]: class extends ProxyComponent { 347 | constructor(options) { 348 | try { 349 | super( 350 | { 351 | Adapter, 352 | id, 353 | debugName, 354 | current, 355 | register: rerender => { 356 | instances.push(rerender) 357 | const unregister = () => { 358 | const i = instances.indexOf(rerender) 359 | instances.splice(i, 1) 360 | } 361 | return unregister 362 | }, 363 | }, 364 | options 365 | ) 366 | } catch (err) { 367 | // If we fail to create a proxy instance, any instance, that means 368 | // that we won't be able to fix this instance when it is updated. 369 | // Recovering to normal state will be impossible. HMR's dead. 370 | // 371 | // Fatal error will trigger a full reload on next update (reloading 372 | // right now is kinda pointless since buggy code still exists). 373 | // 374 | // NOTE Only report first error to avoid too much polution -- following 375 | // errors are probably caused by the first one, or they will show up 376 | // in turn when the first one is fixed ¯\_(ツ)_/¯ 377 | // 378 | if (!fatalError) { 379 | fatalError = true 380 | logError( 381 | `Unrecoverable HMR error in ${debugName}: ` + 382 | `next update will trigger a full reload` 383 | ) 384 | } 385 | throw err 386 | } 387 | } 388 | }, 389 | }[name] 390 | 391 | // initialize static members 392 | let previousStatics = syncStatics(current.Component, proxy) 393 | 394 | const update = newState => Object.assign(current, newState) 395 | 396 | // reload all existing instances of this component 397 | const reload = () => { 398 | fireBeforeUpdate() 399 | 400 | // copy statics before doing anything because a static prop/method 401 | // could be used somewhere in the create/render call 402 | previousStatics = syncStatics(current.Component, proxy, previousStatics) 403 | 404 | const errors = [] 405 | 406 | instances.forEach(rerender => { 407 | try { 408 | rerender() 409 | } catch (err) { 410 | logError(`Failed to rerender ${debugName}`, err) 411 | errors.push(err) 412 | } 413 | }) 414 | 415 | if (errors.length > 0) { 416 | return false 417 | } 418 | 419 | fireAfterUpdate() 420 | 421 | return true 422 | } 423 | 424 | const hasFatalError = () => fatalError 425 | 426 | return { id, proxy, update, reload, hasFatalError, current } 427 | } 428 | -------------------------------------------------------------------------------- /packages/svelte-hmr/runtime/svelte-hooks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Emulates forthcoming HMR hooks in Svelte. 3 | * 4 | * All references to private component state ($$) are now isolated in this 5 | * module. 6 | */ 7 | import { 8 | current_component, 9 | get_current_component, 10 | set_current_component, 11 | } from 'svelte/internal' 12 | 13 | const captureState = cmp => { 14 | // sanity check: propper behaviour here is to crash noisily so that 15 | // user knows that they're looking at something broken 16 | if (!cmp) { 17 | throw new Error('Missing component') 18 | } 19 | if (!cmp.$$) { 20 | throw new Error('Invalid component') 21 | } 22 | 23 | const { 24 | $$: { callbacks, bound, ctx, props }, 25 | } = cmp 26 | 27 | const state = cmp.$capture_state() 28 | 29 | // capturing current value of props (or we'll recreate the component with the 30 | // initial prop values, that may have changed -- and would not be reflected in 31 | // options.props) 32 | const hmr_props_values = {} 33 | Object.keys(cmp.$$.props).forEach(prop => { 34 | hmr_props_values[prop] = ctx[props[prop]] 35 | }) 36 | 37 | return { 38 | ctx, 39 | props, 40 | callbacks, 41 | bound, 42 | state, 43 | hmr_props_values, 44 | } 45 | } 46 | 47 | // remapping all existing bindings (including hmr_future_foo ones) to the 48 | // new version's props indexes, and refresh them with the new value from 49 | // context 50 | const restoreBound = (cmp, restore) => { 51 | // reverse prop:ctxIndex in $$.props to ctxIndex:prop 52 | // 53 | // ctxIndex can be either a regular index in $$.ctx or a hmr_future_ prop 54 | // 55 | const propsByIndex = {} 56 | for (const [name, i] of Object.entries(restore.props)) { 57 | propsByIndex[i] = name 58 | } 59 | 60 | // NOTE $$.bound cannot change in the HMR lifetime of a component, because 61 | // if bindings changes, that means the parent component has changed, 62 | // which means the child (current) component will be wholly recreated 63 | for (const [oldIndex, updateBinding] of Object.entries(restore.bound)) { 64 | // can be either regular prop, or future_hmr_ prop 65 | const propName = propsByIndex[oldIndex] 66 | 67 | // this should never happen if remembering of future props is enabled... 68 | // in any case, there's nothing we can do about it if we have lost prop 69 | // name knowledge at this point 70 | if (propName == null) continue 71 | 72 | // NOTE $$.props[propName] also propagates knowledge of a possible 73 | // future prop to the new $$.props (via $$.props being a Proxy) 74 | const newIndex = cmp.$$.props[propName] 75 | cmp.$$.bound[newIndex] = updateBinding 76 | 77 | // NOTE if the prop doesn't exist or doesn't exist anymore in the new 78 | // version of the component, clearing the binding is the expected 79 | // behaviour (since that's what would happen in non HMR code) 80 | const newValue = cmp.$$.ctx[newIndex] 81 | updateBinding(newValue) 82 | } 83 | } 84 | 85 | // restoreState 86 | // 87 | // It is too late to restore context at this point because component instance 88 | // function has already been called (and so context has already been read). 89 | // Instead, we rely on setting current_component to the same value it has when 90 | // the component was first rendered -- which fix support for context, and is 91 | // also generally more respectful of normal operation. 92 | // 93 | const restoreState = (cmp, restore) => { 94 | if (!restore) return 95 | 96 | if (restore.callbacks) { 97 | cmp.$$.callbacks = restore.callbacks 98 | } 99 | 100 | if (restore.bound) { 101 | restoreBound(cmp, restore) 102 | } 103 | 104 | // props, props.$$slots are restored at component creation (works 105 | // better -- well, at all actually) 106 | } 107 | 108 | const get_current_component_safe = () => { 109 | // NOTE relying on dynamic bindings (current_component) makes us dependent on 110 | // bundler config (and apparently it does not work in demo-svelte-nollup) 111 | try { 112 | // unfortunately, unlike current_component, get_current_component() can 113 | // crash in the normal path (when there is really no parent) 114 | return get_current_component() 115 | } catch (err) { 116 | // ... so we need to consider that this error means that there is no parent 117 | // 118 | // that makes us tightly coupled to the error message but, at least, we 119 | // won't mute an unexpected error, which is quite a horrible thing to do 120 | if (err.message === 'Function called outside component initialization') { 121 | // who knows... 122 | return current_component 123 | } else { 124 | throw err 125 | } 126 | } 127 | } 128 | 129 | export const createProxiedComponent = ( 130 | Component, 131 | initialOptions, 132 | { allowLiveBinding, onInstance, onMount, onDestroy } 133 | ) => { 134 | let cmp 135 | let options = initialOptions 136 | 137 | const isCurrent = _cmp => cmp === _cmp 138 | 139 | const assignOptions = (target, anchor, restore, preserveLocalState) => { 140 | const props = Object.assign({}, options.props) 141 | 142 | // Filtering props to avoid "unexpected prop" warning 143 | // NOTE this is based on props present in initial options, but it should 144 | // always works, because props that are passed from the parent can't 145 | // change without a code change to the parent itself -- hence, the 146 | // child component will be fully recreated, and initial options should 147 | // always represent props that are currnetly passed by the parent 148 | if (options.props && restore.hmr_props_values) { 149 | for (const prop of Object.keys(options.props)) { 150 | if (restore.hmr_props_values.hasOwnProperty(prop)) { 151 | props[prop] = restore.hmr_props_values[prop] 152 | } 153 | } 154 | } 155 | 156 | if (preserveLocalState && restore.state) { 157 | if (Array.isArray(preserveLocalState)) { 158 | // form ['a', 'b'] => preserve only 'a' and 'b' 159 | props.$$inject = {} 160 | for (const key of preserveLocalState) { 161 | props.$$inject[key] = restore.state[key] 162 | } 163 | } else { 164 | props.$$inject = restore.state 165 | } 166 | } else { 167 | delete props.$$inject 168 | } 169 | options = Object.assign({}, initialOptions, { 170 | target, 171 | anchor, 172 | props, 173 | hydrate: false, 174 | }) 175 | } 176 | 177 | // Preserving knowledge of "future props" -- very hackish version (maybe 178 | // there should be an option to opt out of this) 179 | // 180 | // The use case is bind:something where something doesn't exist yet in the 181 | // target component, but comes to exist later, after a HMR update. 182 | // 183 | // If Svelte can't map a prop in the current version of the component, it 184 | // will just completely discard it: 185 | // https://github.com/sveltejs/svelte/blob/1632bca34e4803d6b0e0b0abd652ab5968181860/src/runtime/internal/Component.ts#L46 186 | // 187 | const rememberFutureProps = cmp => { 188 | if (typeof Proxy === 'undefined') return 189 | 190 | cmp.$$.props = new Proxy(cmp.$$.props, { 191 | get(target, name) { 192 | if (target[name] === undefined) { 193 | target[name] = 'hmr_future_' + name 194 | } 195 | return target[name] 196 | }, 197 | set(target, name, value) { 198 | target[name] = value 199 | }, 200 | }) 201 | } 202 | 203 | const instrument = targetCmp => { 204 | const createComponent = (Component, restore, previousCmp) => { 205 | set_current_component(parentComponent || previousCmp) 206 | const comp = new Component(options) 207 | // NOTE must be instrumented before restoreState, because restoring 208 | // bindings relies on hacked $$.props 209 | instrument(comp) 210 | restoreState(comp, restore) 211 | return comp 212 | } 213 | 214 | rememberFutureProps(targetCmp) 215 | 216 | targetCmp.$$.on_hmr = [] 217 | 218 | // `conservative: true` means we want to be sure that the new component has 219 | // actually been successfuly created before destroying the old instance. 220 | // This could be useful for preventing runtime errors in component init to 221 | // bring down the whole HMR. Unfortunately the implementation bellow is 222 | // broken (FIXME), but that remains an interesting target for when HMR hooks 223 | // will actually land in Svelte itself. 224 | // 225 | // The goal would be to render an error inplace in case of error, to avoid 226 | // losing the navigation stack (especially annoying in native, that is not 227 | // based on URL navigation, so we lose the current page on each error). 228 | // 229 | targetCmp.$replace = ( 230 | Component, 231 | { 232 | target = options.target, 233 | anchor = options.anchor, 234 | preserveLocalState, 235 | conservative = false, 236 | } 237 | ) => { 238 | const restore = captureState(targetCmp) 239 | assignOptions( 240 | target || options.target, 241 | anchor, 242 | restore, 243 | preserveLocalState 244 | ) 245 | 246 | const callbacks = cmp ? cmp.$$.on_hmr : [] 247 | 248 | const afterCallbacks = callbacks.map(fn => fn(cmp)).filter(Boolean) 249 | 250 | const previous = cmp 251 | if (conservative) { 252 | try { 253 | const next = createComponent(Component, restore, previous) 254 | // prevents on_destroy from firing on non-final cmp instance 255 | cmp = null 256 | previous.$destroy() 257 | cmp = next 258 | } catch (err) { 259 | cmp = previous 260 | throw err 261 | } 262 | } else { 263 | // prevents on_destroy from firing on non-final cmp instance 264 | cmp = null 265 | if (previous) { 266 | // previous can be null if last constructor has crashed 267 | previous.$destroy() 268 | } 269 | cmp = createComponent(Component, restore, cmp) 270 | } 271 | 272 | cmp.$$.hmr_cmp = cmp 273 | 274 | for (const fn of afterCallbacks) { 275 | fn(cmp) 276 | } 277 | 278 | cmp.$$.on_hmr = callbacks 279 | 280 | return cmp 281 | } 282 | 283 | // NOTE onMount must provide target & anchor (for us to be able to determinate 284 | // actual DOM insertion point) 285 | // 286 | // And also, to support keyed list, it needs to be called each time the 287 | // component is moved (same as $$.fragment.m) 288 | if (onMount) { 289 | const m = targetCmp.$$.fragment.m 290 | targetCmp.$$.fragment.m = (...args) => { 291 | const result = m(...args) 292 | onMount(...args) 293 | return result 294 | } 295 | } 296 | 297 | // NOTE onDestroy must be called even if the call doesn't pass through the 298 | // component's $destroy method (that we can hook onto by ourselves, since 299 | // it's public API) -- this happens a lot in svelte's internals, that 300 | // manipulates cmp.$$.fragment directly, often binding to fragment.d, 301 | // for example 302 | if (onDestroy) { 303 | targetCmp.$$.on_destroy.push(() => { 304 | if (isCurrent(targetCmp)) { 305 | onDestroy() 306 | } 307 | }) 308 | } 309 | 310 | if (onInstance) { 311 | onInstance(targetCmp) 312 | } 313 | 314 | // Svelte 3 creates and mount components from their constructor if 315 | // options.target is present. 316 | // 317 | // This means that at this point, the component's `fragment.c` and, 318 | // most notably, `fragment.m` will already have been called _from inside 319 | // createComponent_. That is: before we have a chance to hook on it. 320 | // 321 | // Proxy's constructor 322 | // -> createComponent 323 | // -> component constructor 324 | // -> component.$$.fragment.c(...) (or l, if hydrate:true) 325 | // -> component.$$.fragment.m(...) 326 | // 327 | // -> you are here <- 328 | // 329 | if (onMount) { 330 | const { target, anchor } = options 331 | if (target) { 332 | onMount(target, anchor) 333 | } 334 | } 335 | } 336 | 337 | const parentComponent = allowLiveBinding 338 | ? current_component 339 | : get_current_component_safe() 340 | 341 | cmp = new Component(options) 342 | cmp.$$.hmr_cmp = cmp 343 | 344 | instrument(cmp) 345 | 346 | return cmp 347 | } 348 | -------------------------------------------------------------------------------- /packages/svelte-hmr/runtime/svelte-native/patch-page-show-modal.js: -------------------------------------------------------------------------------- 1 | // This module monkey patches Page#showModal in order to be able to 2 | // access from the HMR proxy data passed to `showModal` in svelte-native. 3 | // 4 | // Data are stored in a opaque prop accessible with `getModalData`. 5 | // 6 | // It also switches the `closeCallback` option with a custom brewed one 7 | // in order to give the proxy control over when its own instance will be 8 | // destroyed. 9 | // 10 | // Obviously this method suffer from extreme coupling with the target code 11 | // in svelte-native. So it would be wise to recheck compatibility on SN 12 | // version upgrades. 13 | // 14 | // Relevant code is there (last checked version): 15 | // 16 | // https://github.com/halfnelson/svelte-native/blob/48fdc97d2eb4d3958cfcb4ff6cf5755a220829eb/src/dom/navigation.ts#L132 17 | // 18 | 19 | // FIXME should we override ViewBase#showModal instead? 20 | // @ts-ignore 21 | import { Page } from '@nativescript/core' 22 | 23 | const prop = 24 | typeof Symbol !== 'undefined' 25 | ? Symbol('hmr_svelte_native_modal') 26 | : '___HMR_SVELTE_NATIVE_MODAL___' 27 | 28 | const sup = Page.prototype.showModal 29 | 30 | let patched = false 31 | 32 | export const patchShowModal = () => { 33 | // guard: already patched 34 | if (patched) return 35 | patched = true 36 | 37 | Page.prototype.showModal = function(modalView, options) { 38 | const modalData = { 39 | originalOptions: options, 40 | closeCallback: options.closeCallback, 41 | } 42 | 43 | modalView[prop] = modalData 44 | 45 | // Proxies to a function that can be swapped on the fly by HMR proxy. 46 | // 47 | // The default is still to call the original closeCallback from svelte 48 | // navtive, which will destroy the modal view & component. This way, if 49 | // no HMR happens on the modal content, normal behaviour is preserved 50 | // without the proxy having any work to do. 51 | // 52 | const closeCallback = (...args) => { 53 | return modalData.closeCallback(...args) 54 | } 55 | 56 | const tamperedOptions = Object.assign({}, options, { closeCallback }) 57 | 58 | return sup.call(this, modalView, tamperedOptions) 59 | } 60 | } 61 | 62 | export const getModalData = modalView => modalView[prop] 63 | -------------------------------------------------------------------------------- /packages/svelte-hmr/runtime/svelte-native/proxy-adapter-native.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | import { adapter as ProxyAdapterDom } from '../proxy-adapter-dom' 4 | 5 | import { patchShowModal, getModalData } from './patch-page-show-modal' 6 | 7 | patchShowModal() 8 | 9 | // Svelte Native support 10 | // ===================== 11 | // 12 | // Rerendering Svelte Native page proves challenging... 13 | // 14 | // In NativeScript, pages are the top level component. They are normally 15 | // introduced into NativeScript's runtime by its `navigate` function. This 16 | // is how Svelte Natives handles it: it renders the Page component to a 17 | // dummy fragment, and "navigate" to the page element thus created. 18 | // 19 | // As long as modifications only impact child components of the page, then 20 | // we can keep the existing page and replace its content for HMR. 21 | // 22 | // However, if the page component itself is modified (including its system 23 | // title bar), things get hairy... 24 | // 25 | // Apparently, the sole way of introducing a new page in a NS application is 26 | // to navigate to it (no way to just replace it in its parent "element", for 27 | // example). This is how it is done in NS's own "core" HMR. 28 | // 29 | // NOTE The last paragraph has not really been confirmed with NS6. 30 | // 31 | // Unfortunately the API they're using to do that is not public... Its various 32 | // parts remain exposed though (but documented as private), so this exploratory 33 | // work now relies on it. It might be fragile... 34 | // 35 | // The problem is that there is no public API that can navigate to a page and 36 | // replace (like location.replace) the current history entry. Actually there 37 | // is an active issue at NS asking for that. Incidentally, members of 38 | // NativeScript-Vue have commented on the issue to weight in for it -- they 39 | // probably face some similar challenge. 40 | // 41 | // https://github.com/NativeScript/NativeScript/issues/6283 42 | 43 | const getNavTransition = ({ transition }) => { 44 | if (typeof transition === 'string') { 45 | transition = { name: transition } 46 | } 47 | return transition ? { animated: true, transition } : { animated: false } 48 | } 49 | 50 | export const adapter = class ProxyAdapterNative extends ProxyAdapterDom { 51 | constructor(instance) { 52 | super(instance) 53 | 54 | this.nativePageElement = null 55 | } 56 | 57 | dispose() { 58 | super.dispose() 59 | this.releaseNativePageElement() 60 | } 61 | 62 | releaseNativePageElement() { 63 | if (this.nativePageElement) { 64 | // native cleaning will happen when navigating back from the page 65 | this.nativePageElement = null 66 | } 67 | } 68 | 69 | afterMount(target, anchor) { 70 | // nativePageElement needs to be updated each time (only for page 71 | // components, native component that are not pages follow normal flow) 72 | // 73 | // TODO quid of components that are initially a page, but then have the 74 | // tag removed while running? or the opposite? 75 | // 76 | // insertionPoint needs to be updated _only when the target changes_ -- 77 | // i.e. when the component is mount, i.e. (in svelte3) when the component 78 | // is _created_, and svelte3 doesn't allow it to move afterward -- that 79 | // is, insertionPoint only needs to be created once when the component is 80 | // first mounted. 81 | // 82 | // TODO is it really true that components' elements cannot move in the 83 | // DOM? what about keyed list? 84 | // 85 | 86 | const isNativePage = 87 | (target.tagName === 'fragment' || target.tagName === 'frame') && 88 | target.firstChild && 89 | target.firstChild.tagName == 'page' 90 | if (isNativePage) { 91 | const nativePageElement = target.firstChild 92 | this.nativePageElement = nativePageElement 93 | } else { 94 | // try to protect against components changing from page to no-page 95 | // or vice versa -- see DEBUG 1 above. NOT TESTED so prolly not working 96 | this.nativePageElement = null 97 | super.afterMount(target, anchor) 98 | } 99 | } 100 | 101 | rerender() { 102 | const { nativePageElement } = this 103 | if (nativePageElement) { 104 | this.rerenderNative() 105 | } else { 106 | super.rerender() 107 | } 108 | } 109 | 110 | rerenderNative() { 111 | const { nativePageElement: oldPageElement } = this 112 | const nativeView = oldPageElement.nativeView 113 | const frame = nativeView.frame 114 | if (frame) { 115 | return this.rerenderPage(frame, nativeView) 116 | } 117 | const modalParent = nativeView._modalParent // FIXME private API 118 | if (modalParent) { 119 | return this.rerenderModal(modalParent, nativeView) 120 | } 121 | // wtf? hopefully a race condition with a destroyed component, so 122 | // we have nothing more to do here 123 | // 124 | // for once, it happens when hot reloading dev deps, like this file 125 | // 126 | } 127 | 128 | rerenderPage(frame, previousPageView) { 129 | const isCurrentPage = frame.currentPage === previousPageView 130 | if (isCurrentPage) { 131 | const { 132 | instance: { hotOptions }, 133 | } = this 134 | const newPageElement = this.createPage() 135 | if (!newPageElement) { 136 | throw new Error('Failed to create updated page') 137 | } 138 | const isFirstPage = !frame.canGoBack() 139 | const nativeView = newPageElement.nativeView 140 | const navigationEntry = Object.assign( 141 | {}, 142 | { 143 | create: () => nativeView, 144 | clearHistory: true, 145 | }, 146 | getNavTransition(hotOptions) 147 | ) 148 | 149 | if (isFirstPage) { 150 | // NOTE not so sure of bellow with the new NS6 method for replace 151 | // 152 | // The "replacePage" strategy does not work on the first page 153 | // of the stack. 154 | // 155 | // Resulting bug: 156 | // - launch 157 | // - change first page => HMR 158 | // - navigate to other page 159 | // - back 160 | // => actual: back to OS 161 | // => expected: back to page 1 162 | // 163 | // Fortunately, we can overwrite history in this case. 164 | // 165 | frame.navigate(navigationEntry) 166 | } else { 167 | frame.replacePage(navigationEntry) 168 | } 169 | } else { 170 | const backEntry = frame.backStack.find( 171 | ({ resolvedPage: page }) => page === previousPageView 172 | ) 173 | if (!backEntry) { 174 | // well... looks like we didn't make it to history after all 175 | return 176 | } 177 | // replace existing nativeView 178 | const newPageElement = this.createPage() 179 | if (newPageElement) { 180 | backEntry.resolvedPage = newPageElement.nativeView 181 | } else { 182 | throw new Error('Failed to create updated page') 183 | } 184 | } 185 | } 186 | 187 | // modalParent is the page on which showModal(...) was called 188 | // oldPageElement is the modal content, that we're actually trying to reload 189 | rerenderModal(modalParent, modalView) { 190 | const modalData = getModalData(modalView) 191 | 192 | modalData.closeCallback = () => { 193 | const nativePageElement = this.createPage() 194 | if (!nativePageElement) { 195 | throw new Error('Failed to created updated modal page') 196 | } 197 | const { nativeView } = nativePageElement 198 | const { originalOptions } = modalData 199 | // Options will get monkey patched again, the only work left for us 200 | // is to try to reduce visual disturbances. 201 | // 202 | // FIXME Even that proves too much unfortunately... Apparently TNS 203 | // does not respect the `animated` option in this context: 204 | // https://docs.nativescript.org/api-reference/interfaces/_ui_core_view_base_.showmodaloptions#animated 205 | // 206 | const options = Object.assign({}, originalOptions, { animated: false }) 207 | modalParent.showModal(nativeView, options) 208 | } 209 | 210 | modalView.closeModal() 211 | } 212 | 213 | createPage() { 214 | const { 215 | instance: { refreshComponent }, 216 | } = this 217 | const { nativePageElement: oldNativePageElement } = this 218 | const oldNativeView = oldNativePageElement.nativeView 219 | // rerender 220 | const target = document.createElement('fragment') 221 | 222 | // not using conservative for now, since there's nothing in place here to 223 | // leverage it (yet?) -- and it might be easier to miss breakages in native 224 | // only code paths 225 | refreshComponent(target, null) 226 | 227 | // this.nativePageElement is updated in afterMount, triggered by proxy / hooks 228 | const newPageElement = this.nativePageElement 229 | 230 | // svelte-native uses navigateFrom event + e.isBackNavigation to know when to $destroy the component. 231 | // To keep that behaviour after refresh, we move event handler from old native view to the new one using 232 | // __navigateFromHandler property that svelte-native provides us with. 233 | const navigateFromHandler = oldNativeView.__navigateFromHandler 234 | if (navigateFromHandler) { 235 | oldNativeView.off('navigatedFrom', navigateFromHandler) 236 | newPageElement.nativeView.on('navigatedFrom', navigateFromHandler) 237 | newPageElement.nativeView.__navigateFromHandler = navigateFromHandler 238 | delete oldNativeView.__navigateFromHandler 239 | } 240 | 241 | return newPageElement 242 | } 243 | 244 | renderError(err /* , target, anchor */) { 245 | // TODO fallback on TNS error handler for now... at least our error 246 | // is more informative 247 | throw err 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /playground/$test/config.js: -------------------------------------------------------------------------------- 1 | import os from 'node:os' 2 | import path from 'node:path' 3 | 4 | export const GLOBAL_DIR = path.join(os.tmpdir(), 'svelte-hmr_global_setup') 5 | 6 | export const WS_ENDPOINT_FILE = path.join(GLOBAL_DIR, 'wsEndpoint') 7 | 8 | export const GLOBAL_STATE_FILE = path.join(GLOBAL_DIR, 'global_state.json') 9 | 10 | export const ROOT_DIR = path.resolve(__dirname, '../..') 11 | 12 | export const PLAYGROUND_DIR = path.resolve(ROOT_DIR, 'playground') 13 | 14 | export const isCI = !!process.env.CI 15 | export const isKeep = !!process.env.KEEP 16 | export const isOpen = !!process.env.OPEN 17 | -------------------------------------------------------------------------------- /playground/$test/helpers.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import('playwright-chromium').Page} Page */ 2 | 3 | /** 4 | * @param {string} [selector] 5 | * @returns {(page: Page) => Promise} 6 | */ 7 | export const clickButton = 8 | (selector = 'button') => 9 | async (page) => { 10 | const button = await page.$(selector) 11 | await button.click() 12 | } 13 | 14 | /** 15 | * @param {string} selector 16 | * @returns {(page: Page) => Promise} 17 | */ 18 | export const click = (selector) => async (page) => { 19 | const element = await page.$(selector) 20 | await element.click() 21 | } 22 | 23 | /** 24 | * @param {string} [selector] 25 | * @returns {(page: Page) => Promise} 26 | */ 27 | export const clearInput = 28 | (selector = 'input') => 29 | async (page) => { 30 | await page.focus(selector) 31 | const value = await page.inputValue(selector) 32 | const len = String(value).length 33 | for (let i = 0; i < len; i++) { 34 | await page.keyboard.press('Backspace') 35 | } 36 | } 37 | 38 | /** 39 | * @param {string} value 40 | * @param {string} [selector] 41 | * @returns {(page: Page) => Promise} 42 | */ 43 | export const replaceInputValue = 44 | (value, selector = 'input') => 45 | async (page) => { 46 | await clearInput(selector)(page) 47 | await page.type(selector, value) 48 | } 49 | 50 | /** @param {number} ms */ 51 | export const wait = (ms) => async () => 52 | new Promise((resolve) => setTimeout(resolve, ms)) 53 | -------------------------------------------------------------------------------- /playground/$test/hmr-context.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import path from 'node:path' 3 | import { chromium } from 'playwright-chromium' 4 | import { createServer, loadConfigFromFile, mergeConfig } from 'vite' 5 | 6 | import { wsEndpoint, tmpPlaygroundDir } from '../vitestSetup.js' 7 | import { PLAYGROUND_DIR, isKeep } from './config.js' 8 | import { call, randomId, normalizeHtml } from './util.js' 9 | 10 | /** @param {import('vitest').TestContext} ctx */ 11 | export const initHmrTest = (ctx) => { 12 | const projectDirName = path.dirname(ctx.task.file.name) 13 | const testFileName = path 14 | .basename('basic/bindings.spec.js') 15 | .replace(/\.(?:spec|test)\.js$/, '') 16 | const projectDir = path.resolve(PLAYGROUND_DIR, projectDirName) 17 | const projectTmpDir = path.resolve( 18 | tmpPlaygroundDir, 19 | `${projectDirName}-${testFileName}-${randomId()}` 20 | ) 21 | 22 | const initFixtures = async () => { 23 | await fs.mkdir(projectTmpDir) 24 | await fs.copy(projectDir, projectTmpDir, { 25 | /** @param {string} item */ 26 | filter: (item) => !item.endsWith('.spec.js'), 27 | }) 28 | 29 | return { 30 | close: async () => { 31 | await fs.remove(projectTmpDir) 32 | }, 33 | } 34 | } 35 | 36 | const initServer = async () => { 37 | const options = { 38 | root: projectTmpDir, 39 | logLevel: 'silent', 40 | server: { 41 | watch: { 42 | // During tests we edit the files too fast and sometimes chokidar 43 | // misses change events, so enforce polling for consistency 44 | usePolling: true, 45 | interval: 100, 46 | }, 47 | // host: true, 48 | // fs: { 49 | // strict: !isBuild, 50 | // }, 51 | }, 52 | } 53 | const config = await loadConfigFromFile( 54 | { command: 'serve', mode: 'development' }, 55 | undefined, 56 | projectTmpDir 57 | ) 58 | const testConfig = mergeConfig(options, config || {}) 59 | 60 | const server = await createServer(testConfig) 61 | 62 | await server.listen() 63 | 64 | if (isKeep) { 65 | server.printUrls() 66 | } 67 | 68 | const baseUrl = server.resolvedUrls.local[0] 69 | 70 | return { 71 | server, 72 | baseUrl, 73 | close: async () => { 74 | await server.close() 75 | }, 76 | } 77 | } 78 | 79 | const initBrowser = async () => { 80 | const browser = await chromium.connect(wsEndpoint, { 81 | ...(process.env.OPEN && { 82 | slowMo: 100, 83 | }), 84 | }) 85 | 86 | const page = await browser.newPage() 87 | 88 | return { 89 | page, 90 | close: async () => { 91 | await browser?.close() 92 | }, 93 | } 94 | } 95 | 96 | const cleanups = [] 97 | 98 | /** 99 | * @param {{ 100 | * files?: Record 101 | * exposeFunction?: Record 102 | * }} [options] 103 | */ 104 | const start = async ({ files, exposeFunction } = {}) => { 105 | const { close: closeFixtures } = await initFixtures() 106 | 107 | /** 108 | * @param {string} file 109 | * @param {string | ((code: string) => string)} contents 110 | */ 111 | const write = async (file, contents) => { 112 | const filePath = path.resolve(projectTmpDir, file) 113 | 114 | if (typeof contents === 'function') { 115 | const code = await fs.readFile(filePath, 'utf8') 116 | contents = contents(code) 117 | } 118 | 119 | await fs.writeFile(filePath, contents, 'utf8') 120 | } 121 | 122 | if (files) { 123 | await Promise.all( 124 | Object.entries(files).map(([file, contents]) => write(file, contents)) 125 | ) 126 | } 127 | 128 | const { baseUrl, close: closeServer } = await initServer() 129 | 130 | const { page, close: closeBrowser } = await initBrowser() 131 | 132 | if (exposeFunction) { 133 | for (const [name, fn] of Object.entries(exposeFunction)) { 134 | await page.exposeFunction(name, fn) 135 | } 136 | } 137 | 138 | await page.goto(baseUrl) 139 | 140 | if (isKeep) { 141 | cleanups.push(async () => { 142 | await new Promise(() => {}) 143 | }) 144 | } else { 145 | cleanups.push(closeFixtures, closeServer, closeBrowser) 146 | } 147 | 148 | /** @param {string} file */ 149 | const getFileUrl = (file) => `/${file}` 150 | 151 | /** 152 | * @param {string} file 153 | * @param {string | ((code: string) => string)} contents 154 | */ 155 | const edit = async (file, contents) => { 156 | const updatedPromise = new Promise((resolve) => { 157 | // FIXME? 158 | const updateMessage = `[vite] hot updated: ${getFileUrl(file)}` 159 | /** @param {import('playwright-chromium').ConsoleMessage} msg */ 160 | const handleConsole = (msg) => { 161 | // if (msg.text() === updateMessage) { 162 | if (msg.text().startsWith('[vite] hot updated: ')) { 163 | page.off('console', handleConsole) 164 | resolve() 165 | } 166 | } 167 | page.on('console', handleConsole) 168 | }) 169 | 170 | await write(file, contents) 171 | 172 | await updatedPromise 173 | } 174 | 175 | const bodyHTML = async () => { 176 | const body = await page.$('body') 177 | return normalizeHtml(await body.innerHTML()) 178 | } 179 | 180 | return { page, write, edit, bodyHTML } 181 | } 182 | 183 | const stop = async () => { 184 | await Promise.all(cleanups.map(call)) 185 | } 186 | 187 | return { start, stop } 188 | } 189 | -------------------------------------------------------------------------------- /playground/$test/hmr-macro.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'vitest' 2 | import { initHmrTest } from './hmr-context.js' 3 | import { identity, normalizeHtml } from './util.js' 4 | 5 | /** @typedef {import('playwright-chromium').Page} Page */ 6 | 7 | /** 8 | * @typedef {Record< 9 | * string, 10 | * string | Record | ((code: string) => string) 11 | * >} TestHmrStepEdit 12 | * 13 | * @typedef {string | Record} TestHmrStepExpect 14 | * 15 | * @typedef {| { 16 | * name?: string 17 | * expect?: TestHmrStepExpect 18 | * edit?: TestHmrStepEdit 19 | * } 20 | * | ((page: Page) => Promise | void)} TestHmrStepStepsItem 21 | * 22 | * @typedef {TestHmrStepStepsItem | TestHmrStepStepsItem[]} TestHmrStepStepsItems 23 | * 24 | * @typedef {TestHmrStepStepsItems | TestHmrStepStepsItems[]} TestHmrStepSteps 25 | * 26 | * @typedef {{ 27 | * name: string 28 | * edit?: TestHmrStepEdit 29 | * expect?: TestHmrStepExpect 30 | * steps?: TestHmrStepSteps 31 | * }} TestHmrStep 32 | * 33 | * @typedef {Record< 34 | * string, 35 | * string | ((placeholders: Record) => string) 36 | * >} FilesOption 37 | * 38 | * @typedef {Omit & { 39 | * name?: string 40 | * cd?: string 41 | * exposeFunction?: Record 42 | * files?: FilesOption 43 | * }} TestHmrInitStep 44 | */ 45 | 46 | /** 47 | * @typedef {[TestHmrInitStep, ...TestHmrStep[]]} TestHmrArg 48 | * @param {TestHmrArg | ((ctx: import('vitest').TestContext) => TestHmrArg)} _steps 49 | */ 50 | export const hmr = (_steps) => { 51 | /** @param {import('vitest').TestContext} ctx */ 52 | return async (ctx) => { 53 | if (typeof _steps === 'function') { 54 | return hmr(_steps(ctx)) 55 | } 56 | 57 | const { 58 | cd = 'src', 59 | name = 'init', 60 | exposeFunction, 61 | files = {}, 62 | ...init 63 | } = /** @type {TestHmrInitStep} */ (_steps.shift()) 64 | 65 | _steps.unshift({ ...init, name }) 66 | 67 | const steps = /** @type {TestHmrStep[]} */ (_steps) 68 | 69 | const resolve = 70 | cd && cd !== '.' 71 | ? /** @param {string} file */ 72 | (file) => `${cd}/${file}` 73 | : identity 74 | 75 | const { start, stop } = initHmrTest(ctx) 76 | 77 | try { 78 | const { page, edit } = await start({ 79 | exposeFunction, 80 | files: Object.fromEntries( 81 | Object.entries(files).map(([file, contents]) => [ 82 | resolve(file), 83 | typeof contents === 'function' ? contents({}) : contents, 84 | ]) 85 | ), 86 | }) 87 | 88 | for (const step of steps) { 89 | /** @param {(typeof step)['edit']} editSpec */ 90 | const runEdit = async (editSpec) => { 91 | await Promise.all( 92 | Object.entries(editSpec).map(([file, arg]) => { 93 | const resolveContents = () => { 94 | if (typeof arg === 'object') { 95 | const template = files?.[file] 96 | if (template == null) { 97 | throw new Error(`Cannot edit missing file: ${file}`) 98 | } 99 | return /** @type {function} */ (template)(arg) 100 | } else { 101 | return arg 102 | } 103 | } 104 | return edit(resolve(file), resolveContents()) 105 | }) 106 | ) 107 | } 108 | 109 | /** 110 | * @param {(typeof step)['expect']} expect 111 | * @param {string} name 112 | */ 113 | const runExpect = async (expect, name) => { 114 | const expects = 115 | typeof expect === 'string' 116 | ? [['body', expect]] 117 | : Object.entries(expect) 118 | for (const [selector, expected] of expects) { 119 | const el = await page.$(selector) 120 | if (!el) { 121 | throw new Error(`Not found in page: ${selector}`) 122 | } 123 | assert.equal( 124 | normalizeHtml(await el.innerHTML()), 125 | normalizeHtml(expected), 126 | `${name} > ${selector}` 127 | ) 128 | } 129 | } 130 | 131 | if ('edit' in step) { 132 | await runEdit(step.edit) 133 | } 134 | 135 | if ('expect' in step) { 136 | await runExpect(step.expect, step.name) 137 | } 138 | 139 | if ('steps' in step) { 140 | /** @param {TestHmrStepSteps} sub */ 141 | const runSubStep = async (sub, i = 0) => { 142 | if (Array.isArray(sub)) { 143 | let i = 0 144 | for (const subsub of sub) { 145 | await runSubStep(subsub, i++) 146 | } 147 | } else if (typeof sub === 'function') { 148 | await sub(page) 149 | } else { 150 | const unknownOptions = new Set(Object.keys(sub)) 151 | if ('name' in sub) { 152 | unknownOptions.delete('name') 153 | // TODO 154 | } 155 | if ('edit' in sub) { 156 | unknownOptions.delete('edit') 157 | await runEdit(sub.edit) 158 | } 159 | if ('expect' in sub) { 160 | unknownOptions.delete('expect') 161 | await runExpect( 162 | sub.expect, 163 | `${step.name} > ${sub.name || `steps[${i}]`}` 164 | ) 165 | } 166 | if (unknownOptions.size > 0) { 167 | throw new Error( 168 | `Unkown options in step ${step.name}: ${[ 169 | // @ts-ignore 170 | ...unknownOptions, 171 | ].join(', ')}` 172 | ) 173 | } 174 | } 175 | } 176 | 177 | await runSubStep(step.steps) 178 | } 179 | } 180 | } finally { 181 | await stop() 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /playground/$test/index.js: -------------------------------------------------------------------------------- 1 | export { test, describe, assert, expect, vi } from 'vitest' 2 | 3 | export { isCI } from './config.js' 4 | 5 | export * from './util.js' 6 | 7 | export { hmr } from './hmr-macro.js' 8 | 9 | export * from './helpers.js' 10 | -------------------------------------------------------------------------------- /playground/$test/util.js: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto' 2 | import sanitizeHtml from 'sanitize-html' 3 | 4 | /** 5 | * @template T 6 | * @param {() => T} fn 7 | * @returns T 8 | */ 9 | export const call = (fn) => fn() 10 | 11 | export const randomId = () => 12 | crypto.createHash('md5').update(`${Math.random()}${Date.now()}`).digest('hex') 13 | 14 | // https://stackoverflow.com/a/40026669/1387519 15 | const trimRegex = 16 | /(<(pre|script|style|textarea)[^]+?<\/\2)|(^|>)\s+|\s+(?=<|$)/g 17 | 18 | const dedupSpaceRegex = / {2,}/g 19 | 20 | /** @param {string | string[]} html */ 21 | export const normalizeHtml = (html) => { 22 | if (Array.isArray(html)) { 23 | return normalizeHtml(html.join('')) 24 | } 25 | 26 | let result = html 27 | // TODO This is very aggressive reformatting; it could break things that, 28 | // unfortunately, might also be worthy of testing for HMR (whitespaces...) 29 | // Maybe that should become an option of kind (or use a more respectfulj 30 | // sanitization method). 31 | // 32 | // NOTE Many tests (of test utils) depends on this stripping of newlines, 33 | // though. 34 | // 35 | result = result.replace(/\n+/g, ' ') 36 | result = result.trim() 37 | result = sanitizeHtml(result, { 38 | allowVulnerableTags: true, 39 | allowedTags: false, 40 | allowedAttributes: false, 41 | // selfClosing: false, 42 | // allowedSchemes: false, 43 | // allowedSchemesByTag: false, 44 | // allowedSchemesAppliedToAttributes: false, 45 | }) 46 | result = result.replace(trimRegex, '$1$3') 47 | result = result.replace(dedupSpaceRegex, ' ') 48 | return result 49 | } 50 | 51 | /** 52 | * @template T 53 | * @param {T} x 54 | * @returns {T} 55 | */ 56 | export const identity = (x) => x 57 | 58 | /** 59 | * @param {[searchValue: string | RegExp, replacer: string]} args 60 | * @returns {(s: string) => string} 61 | */ 62 | export const replace = 63 | (...args) => 64 | (s) => 65 | s.replace(...args) 66 | 67 | /** @template T */ 68 | export const Deferred = () => { 69 | /** @type {(value: T) => void} */ 70 | let resolve 71 | /** @type {(error: Error) => void} */ 72 | let reject 73 | /** @type {Promise} */ 74 | const promise = new Promise((_resolve, _reject) => { 75 | resolve = _resolve 76 | reject = _reject 77 | }) 78 | return { promise, resolve, reject } 79 | } 80 | -------------------------------------------------------------------------------- /playground/basic/$set.spec.js: -------------------------------------------------------------------------------- 1 | import { test, hmr, clickButton } from '$test' 2 | 3 | test( 4 | 'preserves local state when component changes', 5 | hmr([ 6 | { 7 | files: { 8 | 'App.svelte': ` 9 | 18 | 19 | 27 | 28 | ${text ?? ''} 29 | `, 30 | }, 31 | steps: [ 32 | { expect: ' 0' }, 33 | clickButton(), 34 | { expect: ' 1' }, 35 | clickButton(), 36 | { expect: ' 2' }, 37 | ], 38 | }, 39 | { 40 | name: 'child changes', 41 | edit: { 42 | 'Child.svelte': { text: 'reloaded => ' }, 43 | }, 44 | steps: [ 45 | { expect: ' reloaded => 2' }, 46 | clickButton(), 47 | { expect: ' reloaded => 3' }, 48 | ], 49 | }, 50 | { 51 | name: 'child changes again', 52 | edit: { 53 | 'Child.svelte': { text: 'rere .' }, 54 | }, 55 | steps: [ 56 | { expect: ' rere . 3' }, 57 | clickButton(), 58 | { expect: ' rere . 4' }, 59 | ], 60 | }, 61 | ]) 62 | ) 63 | 64 | test( 65 | 'resets bound values when owner is updated', 66 | hmr([ 67 | { 68 | files: { 69 | 'App.svelte': ({ input }) => ` 70 | 73 | 74 | ${input ?? ''} 75 | 76 |
{value}
77 | `, 78 | }, 79 | steps: [ 80 | { expect: '
123
' }, 81 | replaceInputValue('456'), 82 | { expect: '
456
' }, 83 | ], 84 | }, 85 | { 86 | name: 'change input type', 87 | edit: { 88 | 'App.svelte': { input: '' }, 89 | }, 90 | expect: '
123
', 91 | }, 92 | ]) 93 | ) 94 | 95 | test( 96 | 'instance function are preserved when binding to instance', 97 | hmr([ 98 | { 99 | files: { 100 | 'App.svelte': ` 101 | 114 | 115 | 116 | 117 | {x} 118 | 119 | 0' }, 34 | clickButton(), 35 | { expect: ' 1' }, 36 | ], 37 | }, 38 | { 39 | name: 'callback is attached to new (version of) component', 40 | edit: { 41 | 'Child.svelte': { text: 'updated' }, 42 | }, 43 | steps: [ 44 | { expect: ' updated 1' }, 45 | clickButton(), 46 | { expect: ' updated 2' }, 47 | ], 48 | }, 49 | ]) 50 | ) 51 | -------------------------------------------------------------------------------- /playground/basic/context.spec.js: -------------------------------------------------------------------------------- 1 | import { test, hmr } from '$test' 2 | 3 | test( 4 | 'preserves context when parent is updated', 5 | hmr([ 6 | { 7 | files: { 8 | 'App.svelte': ({ setContext }) => ` 9 | 14 | 15 | 16 | `, 17 | 'Child.svelte': ` 18 | 22 | 23 | I am {name} 24 | `, 25 | }, 26 | expect: 'I am foo', 27 | }, 28 | { 29 | name: 'parent changes', 30 | edit: { 31 | 'App.svelte': { setContext: "setContext('name', 'bar')" }, 32 | }, 33 | expect: 'I am bar', 34 | }, 35 | ]) 36 | ) 37 | 38 | test( 39 | 'preserves context when child is updated', 40 | hmr([ 41 | { 42 | files: { 43 | 'App.svelte': ` 44 | 49 | 50 | 51 | `, 52 | 'Child.svelte': ({ text }) => ` 53 | 57 | 58 | ${text ?? 'I am {name}'} 59 | `, 60 | }, 61 | expect: 'I am foo', 62 | }, 63 | { 64 | name: 'child changes', 65 | edit: { 66 | 'Child.svelte': { text: 'I am {name}!' }, 67 | }, 68 | expect: 'I am foo!', 69 | }, 70 | ]) 71 | ) 72 | 73 | test( 74 | 'preserves context when parent is updated, then child', 75 | hmr([ 76 | { 77 | files: { 78 | 'App.svelte': ({ setContext }) => ` 79 | 84 | 85 | 86 | `, 87 | 'Child.svelte': ({ text }) => ` 88 | 92 | 93 | ${text ?? 'I am {name}'} 94 | `, 95 | }, 96 | expect: 'I am foo', 97 | }, 98 | { 99 | name: 'parent changes', 100 | edit: { 101 | 'App.svelte': { setContext: "setContext('name', 'bar')" }, 102 | }, 103 | expect: 'I am bar', 104 | }, 105 | { 106 | name: 'child changes', 107 | edit: { 108 | 'Child.svelte': { text: 'I am {name}!' }, 109 | }, 110 | expect: 'I am bar!', 111 | }, 112 | ]) 113 | ) 114 | -------------------------------------------------------------------------------- /playground/basic/empty-component.spec.js: -------------------------------------------------------------------------------- 1 | import { test, hmr } from '$test' 2 | 3 | test( 4 | 'does not crash when reloading an empty component', 5 | hmr([ 6 | { 7 | files: { 8 | 'App.svelte': '', 9 | }, 10 | expect: '', 11 | }, 12 | { 13 | name: 'changes', 14 | edit: { 15 | 'App.svelte': 'foo', 16 | }, 17 | expect: 'foo', 18 | }, 19 | { 20 | name: 'clears', 21 | edit: { 22 | 'App.svelte': '', 23 | }, 24 | expect: '', 25 | }, 26 | ]) 27 | ) 28 | -------------------------------------------------------------------------------- /playground/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /playground/basic/keyed-list.spec.js: -------------------------------------------------------------------------------- 1 | import { test, hmr, clickButton } from '$test' 2 | 3 | test( 4 | 'preserves position of reordered child items when child updates', 5 | hmr([ 6 | { 7 | files: { 8 | 'App.svelte': ` 9 | 19 | 20 |