Repository: ionic-team/stencil-store Branch: main Commit: 4a5628ade709 Files: 52 Total size: 94.3 KB Directory structure: gitextract_14714bwu/ ├── .github/ │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yml │ ├── ionic-issue-bot.yml │ └── workflows/ │ ├── build.yml │ ├── main.yml │ ├── publish-npm.yml │ ├── release-dev.yml │ ├── release-orchestrator.yml │ └── release-production.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── rollup.config.js ├── src/ │ ├── index.test.ts │ ├── index.ts │ ├── observable-map.test.ts │ ├── observable-map.ts │ ├── store.test.ts │ ├── store.ts │ ├── subscriptions/ │ │ ├── stencil.test.ts │ │ └── stencil.ts │ ├── types.ts │ ├── utils.test.ts │ └── utils.ts ├── test-app/ │ ├── .editorconfig │ ├── .gitignore │ ├── .prettierrc.json │ ├── LICENSE │ ├── package.json │ ├── readme.md │ ├── src/ │ │ ├── components/ │ │ │ ├── change-store/ │ │ │ │ └── change-store.tsx │ │ │ ├── display-store/ │ │ │ │ ├── display-store.e2e.ts │ │ │ │ └── display-store.tsx │ │ │ └── simple-store/ │ │ │ ├── display-store.spec.ts │ │ │ └── display-store.tsx │ │ ├── components.d.ts │ │ ├── index.html │ │ ├── index.ts │ │ └── utils/ │ │ └── greeting-store.ts │ ├── stencil.config.ts │ └── tsconfig.json ├── tsconfig.json └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CODEOWNERS ================================================ * @stenciljs/technical-steering-committee ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [johnjenkins] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐛 Bug Report description: Create a report to help us improve Stencil Store title: 'bug: ' body: - type: checkboxes attributes: label: Prerequisites description: Please ensure you have completed all of the following. options: - label: I have read the [Contributing Guidelines](https://github.com/stenciljs/core/blob/main/.github/CONTRIBUTING.md). required: true - label: I agree to follow the [Code of Conduct](https://github.com/stenciljs/.github/blob/main/CODE_OF_CONDUCT.md). required: true - label: I have searched for [existing issues](https://github.com/stenciljs/store/issues) that already report this problem, without success. required: true - type: input attributes: label: Stencil Store Version description: The version number of Stencil Store where the issue is occurring. validations: required: true - type: input attributes: label: Stencil Version description: The version number of Stencil where the issue is occurring. validations: required: true - type: textarea attributes: label: Current Behavior description: A clear description of what the bug is and how it manifests. validations: required: true - type: textarea attributes: label: Expected Behavior description: A clear description of what you expected to happen. validations: required: true - type: textarea attributes: label: Steps to Reproduce description: Please explain the steps required to duplicate this issue. validations: required: true - type: input attributes: label: Code Reproduction URL description: Please reproduce this issue in a blank Stencil starter application and provide a link to the repo. Run `npm init stencil` to quickly spin up a Stencil project. This is the best way to ensure this issue is triaged quickly. Issues without a code reproduction may be closed if the Stencil Team cannot reproduce the issue you are reporting. placeholder: https://github.com/... validations: required: true - type: textarea attributes: label: Additional Information description: List any other information that is relevant to your issue. Stack traces, related issues, suggestions on how to fix, Stack Overflow links, forum links, etc. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ contact_links: - name: 💻 Stencil url: https://github.com/stenciljs/core/issues/new/choose about: This issue tracker is not for Stencil compiler issues. Please file compiler issues on the Stencil repo. - name: 📚 Documentation url: https://github.com/stenciljs/site/issues/new/choose about: This issue tracker is not for documentation issues. Please file documentation issues on the Stencil site repo. - name: 📝 Create Stencil CLI url: https://github.com/stenciljs/create-stencil/issues/new/choose about: This issue tracker is not for Create Stencil CLI issues. Please file CLI issues on the Create Stencil CLI repo. - name: 🤔 Support Question url: https://forum.ionicframework.com/ about: This issue tracker is not for support questions. Please post your question on the Ionic Forums. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 💡 Feature Request description: Suggest an idea for Stencil Store title: 'feat: ' body: - type: checkboxes attributes: label: Prerequisites description: Please ensure you have completed all of the following. options: - label: I have read the [Contributing Guidelines](https://github.com/stenciljs/core/blob/main/.github/CONTRIBUTING.md). required: true - label: I agree to follow the [Code of Conduct](https://github.com/stenciljs/.github/blob/main/CODE_OF_CONDUCT.md). required: true - label: I have searched for [existing issues](https://github.com/stenciljs/store/issues) that already include this feature request, without success. required: true - type: textarea attributes: label: Describe the Feature Request description: A clear and concise description of what the feature does. validations: required: true - type: textarea attributes: label: Describe the Use Case description: A clear and concise use case for what problem this feature would solve. validations: required: true - type: textarea attributes: label: Describe Preferred Solution description: A clear and concise description of how you want this feature to be added to Stencil Store. - type: textarea attributes: label: Describe Alternatives description: A clear and concise description of any alternative solutions or features you have considered. - type: textarea attributes: label: Related Code description: If you are able to illustrate the feature request with an example, please provide a sample Stencil component(s). Run `npm init stencil` to quickly spin up a Stencil project. - type: textarea attributes: label: Additional Information description: List any other information that is relevant to your issue. Stack traces, related issues, suggestions on how to implement, Stack Overflow links, forum links, etc. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Pull request checklist Please check if your PR fulfills the following requirements: - [ ] Docs have been reviewed and added / updated if needed (for bug fixes / features) - [ ] Build (`npm run build`) was run locally and any changes were pushed - [ ] Tests (`npm test`) were run locally and passed - [ ] Prettier (`npm run prettier`) was run locally and passed ## Pull request type Please check the type of change your PR introduces: - [ ] Bugfix - [ ] Feature - [ ] Refactoring (no functional changes, no api changes) - [ ] Build related changes - [ ] Documentation content changes - [ ] Other (please describe): ## What is the current behavior? GitHub Issue Number: N/A ## What is the new behavior? - - - ## Does this introduce a breaking change? - [ ] Yes - [ ] No ## Testing ## Other information ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: npm directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 groups: patch-deps-updates-main: update-types: - "patch" minor-deps-updates-main: update-types: - "minor" major-deps-updates-main: update-types: - "major" - package-ecosystem: github-actions directory: "/" schedule: interval: weekly time: "11:00" open-pull-requests-limit: 10 groups: patch-deps-updates: update-types: - "patch" minor-deps-updates: update-types: - "minor" major-deps-updates: update-types: - "major" ================================================ FILE: .github/ionic-issue-bot.yml ================================================ triage: label: triage dryRun: false closeAndLock: labels: - label: "ionitron: support" message: > Thanks for the issue! This issue appears to be a support request. We use this issue tracker exclusively for bug reports and feature requests. Please use our [discord channel](https://chat.stenciljs.com/) for questions about Stencil Store. Thank you for using Stencil Store! - label: "ionitron: missing template" message: > Thanks for the issue! It appears that you have not filled out the provided issue template. We use this issue template in order to gather more information and further assist you. Please create a new issue and ensure the template is fully filled out. Thank you for using Stencil Store! close: true lock: true dryRun: false comment: labels: - label: "ionitron: needs reproduction" message: > Thanks for the issue! This issue has been labeled as `needs reproduction`. This label is added to issues that need a code reproduction. Please reproduce this issue in an Stencil starter component library and Stencil Store. Please provide a way for us to access it (GitHub repo, StackBlitz, etc). Without a reliable code reproduction, it is unlikely we will be able to resolve the issue, leading to it being closed. If you have already provided a code snippet and are seeing this message, it is likely that the code snippet was not enough for our team to reproduce the issue. For a guide on how to create a good reproduction, see our [Contributing Guide](https://github.com/stenciljs/core/blob/main/.github/CONTRIBUTING.md). dryRun: false noReply: maxIssuesPerRun: 100 includePullRequests: false label: Awaiting Reply close: false lock: false dryRun: false noReproduction: days: 14 maxIssuesPerRun: 100 label: "ionitron: needs reproduction" responseLabel: triage exemptProjects: true exemptMilestones: true message: > Thanks for the issue! This issue is being closed due to the lack of a code reproduction. If this is still an issue with the latest version of Stencil & Stencil Store, please create a new issue and ensure the template is fully filled out. Thank you for using Stencil Store! close: true lock: true dryRun: false stale: days: 30 maxIssuesPerRun: 100 exemptLabels: - "Bug: Validated" - "Feature: Want this? Upvote it!" - good first issue - help wanted - Reply Received - Request For Comments - "Resolution: Needs Investigation" - "Resolution: Refine" - triage exemptAssigned: true exemptProjects: true exemptMilestones: true label: "ionitron: stale issue" message: > Thanks for the issue! This issue is being closed due to inactivity. If this is still an issue with the latest version of Stencil, please create a new issue and ensure the template is fully filled out. Thank you for using Stencil Store! close: true lock: true dryRun: false ================================================ FILE: .github/workflows/build.yml ================================================ name: Build Stencil Store on: workflow_call: # Make this a reusable workflow, no value needed # https://docs.github.com/en/actions/using-workflows/reusing-workflows jobs: build_stencil_store: name: Build Stencil Store runs-on: 'ubuntu-latest' steps: - name: 📥 Checkout Code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # the pull_request_target event will consider the HEAD of `main` to be the SHA to use. # attempt to use the SHA associated with a pull request and fallback to HEAD of `main` ref: ${{ github.event_name == 'pull_request_target' && format('refs/pull/{0}/merge', github.event.number) || '' }} persist-credentials: false - name: 🕸️ Get Core Dependencies uses: stenciljs/.github/actions/get-core-dependencies@main - name: 📦 Stencil Store Build run: npm run build shell: bash - name: 🧪 Unit Tests run: npm test shell: bash - name: 📤 Upload Build Artifacts uses: stenciljs/.github/actions/upload-archive@main ================================================ FILE: .github/workflows/main.yml ================================================ name: CI on: push: branches: - 'main' pull_request: branches: - '**' jobs: build: name: (build stencil ${{ matrix.stencil_version }}) strategy: fail-fast: false matrix: # Run with multiple different versions of Stencil in parallel: # 1. DEFAULT - uses the version of Stencil written in `package-lock.json`, keeping the same version used by the # Stencil team as a source of truth # 2. 2 - will install the latest release under major version 2 of Stencil. This should be kept as long as this # library supports Stencil v2.Y.Z # 3. 3 - will install the latest release under major version 3 of Stencil. This should be kept as long as this # library supports Stencil v3.Y.Z # 4. 4 - will install the latest release under major version 4 of Stencil. This should be kept as long as this # library supports Stencil v4.Y.Z stencil_version: ['DEFAULT', '2', '3', '4'] runs-on: ubuntu-latest steps: - name: 📥 Checkout Code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: 🕸️ Get Core Dependencies uses: stenciljs/.github/actions/get-core-dependencies@main - name: 🧪 Test run: npm run test - name: 📦 Install Stencil ${{matrix.stencil_version}} run: npm install --save-dev @stencil/core@${{matrix.stencil_version}} shell: bash if: ${{ matrix.stencil_version != 'DEFAULT' }} - name: 📊 Report Stencil Version run: npm ls @stencil/core shell: bash - name: 🏗️ Build run: npm run build shell: bash - name: 🔗 Create Symlink run: npm link shell: bash - name: 📂 Enter test-app Directory run: cd test-app shell: bash - name: 📦 Install test-app Dependencies run: npm install shell: bash - name: 🔗 Link Stencil Store run: npm link @stencil/store - name: 🏗️ Build test-app run: npm run build shell: bash - name: 🧪 Test test-app run: npm test shell: bash ================================================ FILE: .github/workflows/publish-npm.yml ================================================ name: 'Publish Stencil Store' on: workflow_call: inputs: version: description: 'Version or semver bump to publish.' required: true type: string tag: description: 'npm dist-tag to publish under.' required: true type: string node-version: description: 'Node.js version used for publishing.' required: false type: string default: '20' registry-url: description: 'npm registry URL.' required: false type: string default: 'https://registry.npmjs.org' scope: description: 'npm scope that should use trusted publisher auth.' required: false type: string default: '@stencil' package-directory: description: 'Directory that contains the package to publish.' required: false type: string default: '.' install-command: description: 'Command used to install dependencies. Leave blank to skip.' required: false type: string default: 'npm ci' install-working-directory: description: 'Working directory for the install command.' required: false type: string default: '.' build-command: description: 'Command used to build the package. Leave blank to skip.' required: false type: string default: 'npm run build' build-working-directory: description: 'Working directory for the build command.' required: false type: string default: '.' permissions: contents: write id-token: write jobs: publish: runs-on: ubuntu-latest steps: - name: 📥 Checkout Code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: 🟢 Configure Node for Publish if: ${{ inputs.scope == '' }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ inputs.node-version }} registry-url: ${{ inputs.registry-url }} - name: 🟢 Configure Node for Publish (Scoped) if: ${{ inputs.scope != '' }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ inputs.node-version }} registry-url: ${{ inputs.registry-url }} scope: ${{ inputs.scope }} - name: 🔄 Ensure Latest npm run: npm install -g npm@latest shell: bash - name: 📦 Install Dependencies if: ${{ inputs.install-command != '' }} run: ${{ inputs.install-command }} shell: bash working-directory: ${{ inputs.install-working-directory }} - name: 🛠️ Build Package if: ${{ inputs.build-command != '' }} run: ${{ inputs.build-command }} shell: bash working-directory: ${{ inputs.build-working-directory }} - name: 🏷️ Set Version run: npm version --no-git-tag-version --allow-same-version ${{ inputs.version }} shell: bash working-directory: ${{ inputs.package-directory }} - name: 🔎 Get Version from package.json id: get-version run: | # Normalize package directory path to prevent path traversal issues PKG_DIR="${{ inputs.package-directory }}" if [ "$PKG_DIR" = "." ] || [ -z "$PKG_DIR" ]; then PKG_DIR="." fi # Use path.resolve to safely construct the absolute path VERSION=$(node -e "const path = require('path'); const pkgPath = path.resolve('$PKG_DIR', 'package.json'); const pkg = require(pkgPath); console.log(pkg.version);") if [ -z "$VERSION" ]; then echo "❌ Failed to extract version from $PKG_DIR/package.json. Ensure the file exists and contains a valid 'version' field." >&2 exit 1 fi echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "Extracted version: $VERSION from $PKG_DIR/package.json" shell: bash - name: 📝 Commit Version Changes run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" if [ "${{ inputs.package-directory }}" = "." ]; then git add package.json package-lock.json else git add ${{ inputs.package-directory }}/package.json ${{ inputs.package-directory }}/package-lock.json 2>/dev/null || true fi # Only commit if there are changes if ! git diff --staged --quiet; then git commit -m "chore: bump version to ${{ steps.get-version.outputs.VERSION }}" else echo "No changes to commit" fi shell: bash - name: 🏷️ Create Git Tag # Skip tag creation for dev builds to avoid cluttering the repository if: ${{ inputs.tag != 'dev' }} run: | TAG_NAME="v${{ steps.get-version.outputs.VERSION }}" echo "Checking for existing tag: $TAG_NAME" # Check if tag exists locally if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then echo "Tag $TAG_NAME already exists locally, skipping creation" exit 0 fi # Check if tag exists remotely if git ls-remote --tags origin "$TAG_NAME" | grep -q "$TAG_NAME"; then echo "Tag $TAG_NAME already exists on remote, skipping creation" exit 0 fi # Create the tag echo "Creating annotated tag $TAG_NAME" git tag -a "$TAG_NAME" -m "Release $TAG_NAME" echo "✓ Tag $TAG_NAME created successfully" shell: bash - name: 📤 Push Changes and Tag run: | # Push the commit to the current branch git push origin HEAD # Push the tag (only for non-dev builds) if [ "${{ inputs.tag }}" != "dev" ]; then git push origin "v${{ steps.get-version.outputs.VERSION }}" else echo "Skipping tag push for dev build" fi shell: bash - name: 🚀 Publish to npm run: npm publish --tag ${{ inputs.tag }} --provenance shell: bash working-directory: ${{ inputs.package-directory }} ================================================ FILE: .github/workflows/release-dev.yml ================================================ name: 'Stencil Store Dev Release' on: workflow_call: permissions: contents: write id-token: write jobs: build_stencil_store: name: 🏗️ Build Stencil Store uses: ./.github/workflows/build.yml get_dev_version: name: 🔍 Get Dev Build Version runs-on: ubuntu-latest needs: build_stencil_store outputs: dev-version: ${{ steps.generate-dev-version.outputs.DEV_VERSION }} steps: - name: 📥 Checkout Code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: ⚙️ Generate Dev Version id: generate-dev-version run: | PKG_JSON_VERSION=$(cat package.json | jq -r '.version') GIT_HASH=$(git rev-parse --short HEAD) DEV_VERSION=$PKG_JSON_VERSION-dev.$(date +"%s").$GIT_HASH echo "DEV_VERSION=$DEV_VERSION" >> $GITHUB_OUTPUT shell: bash publish_dev: name: 🚀 Publish Dev Build needs: get_dev_version uses: ./.github/workflows/publish-npm.yml with: tag: dev version: ${{ needs.get_dev_version.outputs.dev-version }} ================================================ FILE: .github/workflows/release-orchestrator.yml ================================================ name: 'Release - Stencil Store' on: workflow_dispatch: inputs: channel: description: 'Which release workflow should run?' required: true type: choice default: dev options: - dev - production bump: description: 'Semver bump for production releases.' required: false type: choice default: patch options: - patch - minor - major permissions: contents: read id-token: write jobs: run-dev: if: ${{ inputs.channel == 'dev' }} uses: ./.github/workflows/release-dev.yml secrets: inherit run-production: if: ${{ inputs.channel == 'production' }} uses: ./.github/workflows/release-production.yml secrets: inherit with: bump: ${{ inputs.bump }} ================================================ FILE: .github/workflows/release-production.yml ================================================ name: 'Stencil Store Production Release' on: workflow_call: inputs: bump: description: 'Semver bump to apply (patch, minor, major).' required: true type: string default: patch permissions: contents: write id-token: write jobs: validate_bump: name: ✅ Validate Bump Input runs-on: ubuntu-latest steps: - name: 🔎 Ensure bump is allowed env: BUMP: ${{ inputs.bump }} run: | case "$BUMP" in patch|minor|major) exit 0 ;; *) echo "::error::Invalid bump input: '$BUMP'. Allowed values: patch, minor, major." exit 1 ;; esac build_stencil_store: name: 🏗️ Build Stencil Store uses: ./.github/workflows/build.yml publish_production: name: 🚀 Publish Production Build needs: - build_stencil_store - validate_bump uses: ./.github/workflows/publish-npm.yml with: tag: latest version: ${{ inputs.bump }} ================================================ FILE: .gitignore ================================================ dist/ www/ loader/ build/ *~ *.sw[mnpcod] *.log *.lock *.tmp *.tmp.* log.txt *.sublime-project *.sublime-workspace *.tgz .stencil/ .idea/ .vscode/ .sass-cache/ .versions/ node_modules/ $RECYCLE.BIN/ .DS_Store Thumbs.db UserInterfaceState.xcuserstate .env coverage ================================================ FILE: .nvmrc ================================================ v22.14.0 ================================================ FILE: .prettierrc.json ================================================ { "parser": "typescript", "printWidth": 100, "semi": true, "singleQuote": true, "trailingComma": "es5" } ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to @stencil/store Thank you for your interest in contributing to @stencil/store! This document provides guidelines and information for contributors. ## Table of Contents - [Code of Conduct](#code-of-conduct) - [Getting Started](#getting-started) - [Development Setup](#development-setup) - [Making Changes](#making-changes) - [Testing](#testing) - [Pull Request Process](#pull-request-process) - [Release Process](#release-process) ## Code of Conduct This project follows the same code of conduct as the main StencilJS project. Please be respectful and inclusive in all interactions. ## Getting Started 1. Fork the repository on GitHub 2. Clone your fork locally: ```bash git clone https://github.com/YOUR_USERNAME/store.git cd store ``` 3. Add the upstream repository: ```bash git remote add upstream https://github.com/stenciljs/store.git ``` ## Development Setup ### Prerequisites - Node.js >= 22.14.0 - npm >= 9.x ### Installation ```bash npm install ``` ### Available Scripts - `npm run build` - Build the project - `npm test` - Run all tests - `npm run test.unit` - Run unit tests with Vitest - `npm run test.prettier` - Check code formatting - `npm run prettier` - Format code with Prettier ### Project Structure ``` src/ ├── index.ts # Main entry point ├── store.ts # Core store implementation ├── observable-map.ts # Observable map utilities ├── types.ts # TypeScript type definitions ├── utils.ts # Utility functions └── subscriptions/ └── stencil.ts # Stencil-specific subscriptions ``` ## Making Changes 1. Create a new branch from `main`: ```bash git checkout -b feature/your-feature-name ``` 2. Make your changes following these guidelines: - Write clear, readable code - Add tests for new functionality - Update documentation if needed - Follow the existing code style 3. Run tests to ensure everything works: ```bash npm test ``` 4. Commit your changes with a clear message: ```bash git commit -m "feat: add new feature description" ``` ### Commit Message Guidelines Use conventional commit format: - `feat:` - New features - `fix:` - Bug fixes - `docs:` - Documentation changes - `style:` - Code style changes (formatting, etc.) - `refactor:` - Code refactoring - `test:` - Adding or updating tests - `chore:` - Build process or auxiliary tool changes ## Testing ### Unit Tests The project uses Vitest for unit testing. Tests are located alongside the source files with `.test.ts` extensions. ```bash npm run test.unit ``` ### Test App There's a test application in the `test-app/` directory that demonstrates the store functionality: ```bash cd test-app npm install npm start ``` ### Writing Tests When adding new features or fixing bugs: 1. Add unit tests in the appropriate `.test.ts` file 2. Test both positive and negative cases 3. Include edge cases 4. Ensure tests are isolated and don't depend on external state ## Pull Request Process 1. Push your branch to your fork: ```bash git push origin feature/your-feature-name ``` 2. Create a Pull Request on GitHub with: - Clear title and description - Reference any related issues - Include tests for new functionality - Update documentation if needed 3. Ensure all checks pass: - All tests must pass - Code must be properly formatted - No linting errors 4. Address any review feedback 5. Once approved, a maintainer will merge your PR ## Release Process ### Release Types We follow [Semantic Versioning (SemVer)](https://semver.org/) for releases. Choose the appropriate release type based on your changes: #### Patch Release (x.x.X) Use for: - Bug fixes - Documentation updates - Internal refactoring that doesn't change the API - Performance improvements without API changes #### Minor Release (x.X.x) Use for: - New features that are backward compatible - New API methods or options - Deprecating functionality (without removing) - Significant internal improvements #### Major Release (X.x.x) Use for: - Breaking changes to the public API - Removing deprecated functionality - Changes that require users to modify their code - Major architectural changes ### Development Releases Development Releases (or "Dev Releases", "Dev Builds") are installable instances of Stencil Store that are: - Published to the npm registry for distribution within and outside the Stencil team - Built using the same infrastructure as production releases, with less safety checks - Used to verify a fix or change to the project prior to a production release #### How to Publish Dev Releases Only members of the Stencil team may create dev builds of Stencil Store. To publish a dev build: 1. Navigate to the [Stencil Store Release GitHub Action](https://github.com/stenciljs/store/actions/workflows/release.yml) in your browser. 2. Click the 'Run Workflow' dropdown on the right hand side of the page 3. Configure the workflow inputs: - **Release type**: Select `patch`, `minor`, or `major` (this won't affect the dev build version) - **Dev Release**: Select `yes` to create a dev build 4. Select the branch you want to build from 5. Click 'Run Workflow' 6. Allow the workflow to run. Upon completion, the output of the 'Publish Dev Build' action will report the published version string. Following a successful run of the workflow, the package can be installed from the npm registry like any other package. #### Dev Release Format Dev Builds are published to the NPM registry under the `@stencil/store` scope. Unlike production builds, dev builds use a specially formatted version string to express its origins. Dev builds follow the format `BASE_VERSION-dev.EPOCH_DATE.SHA`, where: - `BASE_VERSION` is the latest production release changes to the build were based off of - `EPOCH_DATE` is the number of seconds since January 1st, 1970 in UTC - `SHA` is the git short SHA of the commit used in the release As an example: `2.1.0-dev.1677185104.7c87e34` was built: - With v2.1.0 as the latest production build at the time of the dev build - On Fri, 26 Jan 2024 13:48:17 UTC - With the commit `7c87e34` ### Production Releases Only members of the Stencil team may create production releases of Stencil Store. #### How to Publish Production Releases 1. Navigate to the [Stencil Store Release GitHub Action](https://github.com/stenciljs/store/actions/workflows/release.yml) in your browser. 2. Click the 'Run Workflow' dropdown on the right hand side of the page 3. Configure the workflow inputs: - **Release type**: Select the appropriate type (`patch`, `minor`, or `major`) based on your changes - **Dev Release**: Select `no` for production releases 4. Select the `main` branch (production releases should only be made from main) 5. Click 'Run Workflow' 6. The workflow will: - Build and test the package - Bump the version according to the selected release type - Create a Git tag - Publish to NPM with the `latest` tag - Create a GitHub release #### Release Checklist Before creating a production release: - [ ] Ensure all intended changes are merged to `main` - [ ] All CI checks are passing - [ ] Update any relevant documentation - [ ] Consider if this should be a dev release first for testing - [ ] Choose the correct release type (patch/minor/major) - [ ] Notify the team of the planned release ## Questions or Issues? - Create an issue on GitHub for bugs or feature requests - Check existing issues before creating new ones - Provide detailed information including steps to reproduce for bugs Thank you for contributing to @stencil/store! 🎉 ================================================ FILE: LICENSE ================================================ Copyright 2015-present Drifty Co. http://drifty.com/ MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # @stencil/store Store is a lightweight shared state library by the [StencilJS](https://stenciljs.com/) core team. It implements a simple key/value map that efficiently re-renders components when necessary. **Highlights:** - 🪶 Lightweight - ⚡ Zero dependencies - 📦 Simple API, like a reactive Map - 🚀 Best performance ## Installation ``` npm install @stencil/store --save-dev ``` ## Example **store.ts:** ```ts import { createStore } from "@stencil/store"; const { state, onChange } = createStore({ clicks: 0, seconds: 0, squaredClicks: 0 }); onChange('clicks', value => { state.squaredClicks = value ** 2; }); export default state; ``` **component.tsx:** ```tsx import { Component, h } from '@stencil/core'; import state from '../store'; @Component({ tag: 'app-profile', }) export class AppProfile { componentWillLoad() { setInterval(() => state.seconds++, 1000); } render() { return (

Seconds: {state.seconds}
Squared Clicks: {state.squaredClicks}

); } } const MyGlobalCounter = () => { return ( ); }; ``` ## API ### `createStore(initialState?: T | (() => T), shouldUpdate?)` Create a new store with the given initial state. The type is inferred from `initialState`, or can be passed as the generic type `T`. `initialState` can be a function that returns the actual initial state. This feature is just in case you have deep objects that mutate as otherwise we cannot keep track of those. ```ts const { reset, state } = createStore(() => ({ pageA: { count: 1 }, pageB: { count: 1 } })); state.pageA.count = 2; state.pageB.count = 3; reset(); state.pageA.count; // 1 state.pageB.count; // 1 ``` Please, bear in mind that the object needs to be created inside the function, not just referenced. The following example won't work as you might want it to, as the returned object is always the same one. ```ts const object = { pageA: { count: 1 }, pageB: { count: 1 } }; const { reset, state } = createStore(() => object); state.pageA.count = 2; state.pageB.count = 3; reset(); state.pageA.count; // 2 state.pageB.count; // 3 ``` By default, store performs a exact comparison (`===`) between the new value, and the previous one in order to prevent unnecessary rerenders, however, this behaviour can be changed by providing a `shouldUpdate` function through the second argument. When this function returns `false`, the value won't be updated. By providing a custom `shouldUpdate()` function, applications can create their own fine-grained change detection logic, beyond the default `===`. This may be useful for certain use-cases to avoid any expensive re-rendering. ```ts const shouldUpdate = (newValue, oldValue, propChanged) => { return JSON.stringify(newValue) !== JSON.stringify(oldValue); } ``` Returns a `store` object with the following properties. #### `store.state` The state object is proxied, i. e. you can directly get and set properties. If you access the state object in the `render` function of your component, Store will automatically re-render it when the state object is changed. Note: [`Proxy`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) objects are not supported by IE11 (not even with a polyfill), so you need to use the `store.get` and `store.set` methods of the API if you wish to support IE11. #### `store.on(event, listener)` Add a listener to the store for a certain action. #### `store.onChange(propName, listener)` Add a listener that is called when a specific property changes (either from a `set` or `reset`). #### `store.get(propName)` Get a property's value from the store. #### `store.set(propName, value)` Set a property's value in the store. #### `store.reset()` Reset the store to its initial state. #### `store.use(...subscriptions)` Use the given subscriptions in the store. A subscription is an object that defines one or more of the properties `get`, `set` or `reset`. ```ts const { reset, state, use } = createStore({ a: 1, b: 2}); const unlog = use({ get: (key) => { console.log(`Someone's reading prop ${key}`); }, set: (key, newValue, oldValue) => { console.log(`Prop ${key} changed from ${oldValue} to ${newValue}`); }, reset: () => { console.log('Store got reset'); }, dispose: () => { console.log('Store got disposed'); }, }) state.a; // Someone's reading prop a state.b = 3; // Prop b changed from 2 to 3 reset(); // Store got reset unlog(); state.a; // Nothing is logged state.b = 5; // Nothing is logged reset(); // Nothing is logged ``` #### `store.dispose()` Resets the store and all the internal state of the store that should not survive between tests. ## Testing Like any global state library, state should be `dispose`d between each spec test. Use the `dispose()` API in the `beforeEach` hook. ```ts import store from '../store'; beforeEach(() => { store.dispose(); }); ``` ================================================ FILE: package.json ================================================ { "name": "@stencil/store", "author": "StencilJS Team", "version": "2.2.2", "description": "Store is a lightweight shared state library by the StencilJS core team. Implements a simple key/value map that efficiently re-renders components when necessary.", "license": "MIT", "homepage": "https://stenciljs.com/docs/stencil-store", "repository": { "type": "git", "url": "git://github.com/stenciljs/store.git" }, "keywords": [ "stencil", "redux", "global", "state", "tunnel", "hooks" ], "type": "module", "module": "dist/index.js", "main": "dist/index.cjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } }, "engines": { "node": ">=18.0.0", "npm": ">=6.0.0" }, "scripts": { "build": "run-s build.clean build.rollup", "build.clean": "rimraf dist", "build.rollup": "rollup -c rollup.config.js", "prettier": "npm run prettier.base -- --write", "prettier.base": "prettier --cache \"src/**/*.ts\"", "prettier.dry-run": "npm run prettier.base -- --list-different", "release": "np", "test": "run-s test.*", "test.prettier": "npm run prettier.dry-run", "test.unit": "vitest", "version": "npm run build" }, "files": [ "dist" ], "peerDependencies": { "@stencil/core": ">=2.0.0 || >=3.0.0 || >= 4.0.0-beta.0 || >= 4.0.0" }, "devDependencies": { "@ionic/prettier-config": "^4.0.0", "@rollup/plugin-typescript": "^12.3.0", "@stencil/core": "^4.38.2", "@types/node": "^25.0.2", "@vitest/coverage-v8": "^4.0.5", "np": "^11.0.1", "npm-run-all2": "^8.0.4", "prettier": "^3.6.2", "rimraf": "^6.0.1", "rollup": "^4.52.5", "typescript": "~6.0.2", "vitest": "^4.0.5" }, "prettier": "@ionic/prettier-config" } ================================================ FILE: rollup.config.js ================================================ import typescript from '@rollup/plugin-typescript'; import pkg from './package.json' with { type: 'json' }; export default { input: 'src/index.ts', output: [ { format: 'cjs', file: pkg.main }, { format: 'esm', file: pkg.module }, ], external: ['@stencil/core'], plugins: [typescript({ outDir: 'dist' })], }; ================================================ FILE: src/index.test.ts ================================================ import { describe, it, expect } from 'vitest'; import { createStore, createObservableMap } from './index'; describe('store', () => { it('exports createStore and createObservableMap', () => { expect(createStore).toBeDefined(); expect(createObservableMap).toBeDefined(); }); }); ================================================ FILE: src/index.ts ================================================ export { createStore } from './store'; export { createObservableMap } from './observable-map'; // Types export { ObservableMap, Subscription } from './types'; ================================================ FILE: src/observable-map.test.ts ================================================ import { describe, expect, test, vi } from 'vitest'; import { createObservableMap } from './observable-map'; describe.each([ ['reset', 'reset'], ['dispose calls reset', 'dispose'], ] as [string, 'reset' | 'dispose'][])('%s', (_, methodName) => { test('returns all variable to their original state', () => { const { [methodName]: method, state } = createObservableMap({ hola: 'hola', name: 'Sergio', }); state.hola = 'hello'; state.name = 'Manu'; expect(state.hola).toBe('hello'); expect(state.name).toBe('Manu'); method(); expect(state.hola).toBe('hola'); expect(state.name).toBe('Sergio'); }); test('extra properties get removed', () => { const { [methodName]: method, state } = createObservableMap>({}); state['hola'] = 'hello'; expect(state).toHaveProperty('hola'); expect(state['hola']).toBe('hello'); method(); expect('hola' in state).toBe(false); }); test('calls on', () => { const { [methodName]: method, on } = createObservableMap({ hola: 'hello' }); const subscription = vi.fn(); on('reset', subscription); method(); expect(subscription).toHaveBeenCalledTimes(1); }); }); test('falls back to plain objects when Proxy is unavailable', () => { const originalProxy = globalThis.Proxy; // @ts-expect-error - simulating environments without Proxy support globalThis.Proxy = undefined; try { const store = createObservableMap({ count: 1 }); store.set('count', 2); store.set('extra', 3); expect(store.get('count')).toBe(2); expect('extra' in store.state).toBe(true); store.reset(); expect(store.get('count')).toBe(1); expect('extra' in store.state).toBe(false); } finally { globalThis.Proxy = originalProxy; } }); test('reset restores empty state when no default state is provided', () => { const store = createObservableMap>(); store.set('num', 1); expect(store.get('num')).toBe(1); store.reset(); expect(store.get('num')).toBeUndefined(); }); describe('dispose', () => { test('calls on', () => { const { dispose, on } = createObservableMap({ hola: 'hello' }); const subscription = vi.fn(); on('dispose', subscription); dispose(); expect(subscription).toHaveBeenCalledTimes(1); }); }); describe.each([ ['proxy', (state, _get, property) => state[property]], ['get fn', (_state, get, property) => get(property)], ] as [string, (s: T, get: (prop: K) => T[K], prop: K) => T[K]][])('get (%s)', (_, getter) => { test('returns the value for the property in the store', () => { const { get, state } = createObservableMap({ hola: 'hello', }); expect(getter(state, get, 'hola')).toBe('hello'); }); test('returns the modified value after being set', () => { const { get, state } = createObservableMap({ hola: 'hello', }); state.hola = 'ola'; expect(getter(state, get, 'hola')).toBe('ola'); }); test('calls on', () => { const { get, on, state } = createObservableMap({ hola: 'hello', }); const subscription = vi.fn(); on('get', subscription); getter(state, get, 'hola'); expect(subscription).toHaveBeenCalledWith('hola'); }); }); describe.each([ ['proxy', (state, _set, prop, value) => (state[prop] = value)], ['set fn', (_state, set, prop, value) => set(prop, value)], ] as [string, (s: T, set: (prop: K, value: T[K]) => void, prop: K, value: T[K]) => void][])( 'set (%s)', (_, setter) => { test('sets the value for a property', () => { const { set, state } = createObservableMap({ hola: 'hello', }); setter(state, set, 'hola', 'ola'); expect(state.hola).toBe('ola'); }); test('calls on', () => { const { set, on, state } = createObservableMap({ hola: 'hello', }); const subscription = vi.fn(); on('set', subscription); setter(state, set, 'hola', 'ola'); expect(subscription).toHaveBeenCalledWith('hola', 'ola', 'hello'); }); test('calls onChange', () => { const { set, onChange, state } = createObservableMap({ hola: 'hello', }); const subscription = vi.fn(); onChange('hola', subscription); setter(state, set, 'hola', 'ola'); expect(subscription).toHaveBeenCalledWith('ola'); }); test('enumerable keys', () => { const { state } = createObservableMap({}); expect(Object.keys(state)).toEqual([]); state.hello = 'hola'; expect(Object.keys(state)).toEqual(['hello']); expect(Object.getOwnPropertyNames(state)).toEqual(['hello']); const copy = { ...state }; expect(copy).toEqual({ hello: 'hola' }); }); test('in operator', () => { const { state } = createObservableMap({}); expect('hello' in state).toBe(false); state.hello = 'hola'; expect('hello' in state).toBe(true); }); }, ); describe('using a function as initial value', () => { test('function gets invoked', () => { const fn = vi.fn().mockReturnValue({ a: 1 }); createObservableMap(fn); // We should not need to call this more than once // when creating the proxy. expect(fn).toHaveBeenCalledTimes(1); }); test('returned value is used as object', () => { const fn = vi.fn().mockReturnValue({ a: 1 }); const { state } = createObservableMap(fn); expect(state).toHaveProperty('a', 1); }); test('resetting resets deep objects', () => { const fn = () => ({ a: { b: 1, c: 2, }, d: [1, 2], }); const { reset, state } = createObservableMap(fn); state.a.b = 3; state.a.c = 5; state.d.push(1, 2); reset(); expect(state.a).toHaveProperty('b', 1); expect(state.a).toHaveProperty('c', 2); expect(state).toHaveProperty('d', [1, 2]); }); test('if the function does not create a new object, there is nothing we can do', () => { const object = { a: { b: 1, c: 2, }, d: [1, 2], }; const fn = () => object; const { reset, state } = createObservableMap(fn); state.a.b = 3; state.a.c = 5; state.d.push(1, 2); reset(); expect(state.a).toHaveProperty('b', 3); expect(state.a).toHaveProperty('c', 5); expect(state).toHaveProperty('d', [1, 2, 1, 2]); }); }); test('unregister events', () => { const { reset, state, on, onChange } = createObservableMap({ hola: 'hola', name: 'Sergio', }); const SET = vi.fn(); const GET = vi.fn(); const RESET = vi.fn(); const CHANGE = vi.fn(); const unset = on('set', SET); const unget = on('get', GET); const unreset = on('reset', RESET); const unChange = onChange('hola', CHANGE); state.hola = 'hola2'; state.name = 'hola2'; expect(SET).toHaveBeenCalledTimes(2); unset(); state.hola = 'hola3'; expect(SET).toHaveBeenCalledTimes(2); state.hola; state.name; expect(GET).toHaveBeenCalledTimes(2); unget(); state.name; expect(GET).toHaveBeenCalledTimes(2); reset(); reset(); expect(RESET).toHaveBeenCalledTimes(2); unreset(); reset(); expect(RESET).toHaveBeenCalledTimes(2); expect(CHANGE).toHaveBeenCalledTimes(5); unChange(); reset(); state.hola = 'hola'; expect(CHANGE).toHaveBeenCalledTimes(5); }); test('default change detector', () => { const store = createObservableMap({ str: 'hola', }); const SET = vi.fn(); store.on('set', SET); store.state.str = 'hola'; expect(SET).not.toBeCalled(); store.state.str = 'hola2'; expect(SET).toBeCalledWith('str', 'hola2', 'hola'); }); test('custom change detector, values', () => { const comparer = vi.fn((a, b) => a !== b); const store = createObservableMap( { str: 'hola', }, comparer, ); store.state.str = 'hola'; expect(comparer).toBeCalledWith('hola', 'hola', 'str'); store.state.str = 'hola2'; expect(comparer).toBeCalledWith('hola2', 'hola', 'str'); store.state.str = 'hola3'; expect(comparer).toBeCalledWith('hola3', 'hola2', 'str'); }); test('custom change detector, prevent all mutations', () => { const store = createObservableMap( { str: 'hola', }, () => false, ); const SET = vi.fn(); store.on('set', SET); store.state.str = 'hola'; expect(SET).not.toBeCalled(); store.state.str = 'hola2'; expect(SET).not.toBeCalled(); expect(store.state.str).toEqual('hola'); }); describe('use subscriptions', () => { test('get is called whenever we get a prop', () => { const store = createObservableMap({ str: 'hola' }); const get = vi.fn(); store.use({ get }); store.state.str; expect(get).toHaveBeenCalledTimes(1); expect(get).toHaveBeenCalledWith('str'); }); test('get is unregistered', () => { const store = createObservableMap({ str: 'hola' }); const get = vi.fn(); const unregister = store.use({ get }); store.state.str; expect(get).toHaveBeenCalledTimes(1); get.mockClear(); unregister(); store.state.str; expect(get).not.toHaveBeenCalled(); }); test('set is called whenever we set a prop', () => { const store = createObservableMap({ str: 'hola' }); const set = vi.fn(); store.use({ set }); store.state.str = 'adios'; expect(set).toHaveBeenCalledTimes(1); expect(set).toHaveBeenCalledWith('str', 'adios', 'hola'); }); test('set is unregistered', () => { const store = createObservableMap({ str: 'hola' }); const set = vi.fn(); const unregister = store.use({ set }); store.state.str = 'adios'; expect(set).toHaveBeenCalledTimes(1); set.mockClear(); unregister(); store.state.str = 'hello'; expect(set).not.toHaveBeenCalled(); }); test('reset is called when we reset the store', () => { const store = createObservableMap({ str: 'hola' }); const reset = vi.fn(); store.use({ reset }); store.reset(); expect(reset).toHaveBeenCalledTimes(1); }); test('reset is unregistered', () => { const store = createObservableMap({ str: 'hola' }); const reset = vi.fn(); const unregister = store.use({ reset }); store.reset(); expect(reset).toHaveBeenCalledTimes(1); reset.mockClear(); unregister(); store.reset(); expect(reset).not.toHaveBeenCalled(); }); test('dispose is called when we dispose the store', () => { const store = createObservableMap({ str: 'hola' }); const dispose = vi.fn(); store.use({ dispose }); store.dispose(); expect(dispose).toHaveBeenCalledTimes(1); }); test('dispose is unregistered', () => { const store = createObservableMap({ str: 'hola' }); const dispose = vi.fn(); const unregister = store.use({ dispose }); store.dispose(); expect(dispose).toHaveBeenCalledTimes(1); dispose.mockClear(); unregister(); store.dispose(); expect(dispose).not.toHaveBeenCalled(); }); test('subscription with several properties subscribes to all of them', () => { const store = createObservableMap({ str: 'hola' }); const subscription = { dispose: vi.fn(), get: vi.fn(), reset: vi.fn(), set: vi.fn(), }; store.use(subscription); store.state.str; expect(subscription.get).toHaveBeenCalledTimes(1); store.state.str = 'adios'; expect(subscription.set).toHaveBeenCalledTimes(1); store.reset(); expect(subscription.reset).toHaveBeenCalledTimes(1); store.dispose(); expect(subscription.dispose).toHaveBeenCalledTimes(1); }); test('subscription with several properties can be unregistered', () => { const store = createObservableMap({ str: 'hola' }); const subscription = { dispose: vi.fn(), get: vi.fn(), reset: vi.fn(), set: vi.fn(), }; const unregister = store.use(subscription); store.state.str; expect(subscription.get).toHaveBeenCalledTimes(1); store.state.str = 'adios'; expect(subscription.set).toHaveBeenCalledTimes(1); store.reset(); expect(subscription.reset).toHaveBeenCalledTimes(1); store.dispose(); expect(subscription.dispose).toHaveBeenCalledTimes(1); vi.clearAllMocks(); unregister(); store.state.str; expect(subscription.get).not.toHaveBeenCalled(); store.state.str = 'adios'; expect(subscription.set).not.toHaveBeenCalled(); store.reset(); expect(subscription.reset).not.toHaveBeenCalled(); store.dispose(); expect(subscription.dispose).not.toHaveBeenCalled(); }); test('use can be passed several subscriptions', () => { const store = createObservableMap({ str: 'hola' }); const subscription = { dispose: vi.fn(), get: vi.fn(), reset: vi.fn(), set: vi.fn(), }; const subscription2 = { dispose: vi.fn(), get: vi.fn(), reset: vi.fn(), set: vi.fn(), }; store.use(subscription, subscription2); store.state.str; expect(subscription.get).toHaveBeenCalledTimes(1); expect(subscription2.get).toHaveBeenCalledTimes(1); store.state.str = 'adios'; expect(subscription.set).toHaveBeenCalledTimes(1); expect(subscription2.set).toHaveBeenCalledTimes(1); store.reset(); expect(subscription.reset).toHaveBeenCalledTimes(1); expect(subscription2.reset).toHaveBeenCalledTimes(1); store.dispose(); expect(subscription.dispose).toHaveBeenCalledTimes(1); expect(subscription2.dispose).toHaveBeenCalledTimes(1); }); test('use can be passed several subscriptions and unregisters them all', () => { const store = createObservableMap({ str: 'hola' }); const subscription = { dispose: vi.fn(), get: vi.fn(), reset: vi.fn(), set: vi.fn(), }; const subscription2 = { dispose: vi.fn(), get: vi.fn(), reset: vi.fn(), set: vi.fn(), }; const unregister = store.use(subscription, subscription2); store.state.str; expect(subscription.get).toHaveBeenCalledTimes(1); expect(subscription2.get).toHaveBeenCalledTimes(1); store.state.str = 'adios'; expect(subscription.set).toHaveBeenCalledTimes(1); expect(subscription2.set).toHaveBeenCalledTimes(1); store.reset(); expect(subscription.reset).toHaveBeenCalledTimes(1); expect(subscription2.reset).toHaveBeenCalledTimes(1); store.dispose(); expect(subscription.dispose).toHaveBeenCalledTimes(1); expect(subscription2.dispose).toHaveBeenCalledTimes(1); vi.clearAllMocks(); unregister(); store.state.str; expect(subscription.get).not.toHaveBeenCalled(); expect(subscription2.get).not.toHaveBeenCalled(); store.state.str = 'adios'; expect(subscription.set).not.toHaveBeenCalled(); expect(subscription2.set).not.toHaveBeenCalled(); store.reset(); expect(subscription.reset).not.toHaveBeenCalled(); expect(subscription2.reset).not.toHaveBeenCalled(); store.dispose(); expect(subscription.dispose).not.toHaveBeenCalled(); expect(subscription2.dispose).not.toHaveBeenCalled(); }); }); describe('removeListener', () => { test('removes a listener from the set event', () => { const store = createObservableMap({ str: 'hola' }); const listener = vi.fn(); store.onChange('str', listener); store.state.str = 'adios'; expect(listener).toHaveBeenCalledWith('adios'); store.removeListener('str', listener); store.state.str = 'hello'; expect(listener).toHaveBeenCalledTimes(1); }); test('removes a listener from the reset event', () => { const store = createObservableMap({ str: 'hola' }); const listener = vi.fn(); store.onChange('str', listener); store.reset(); expect(listener).toHaveBeenCalledWith('hola'); store.removeListener('str', listener); store.reset(); expect(listener).toHaveBeenCalledTimes(1); }); test('ignores unknown listeners', () => { const store = createObservableMap({ str: 'hola' }); const listener = vi.fn(); store.removeListener('str', listener); // should not throw and should not start tracking the listener store.set('str', 'hola2'); expect(listener).not.toHaveBeenCalled(); }); }); test('forceUpdate', () => { const store = createObservableMap({ str: 'hola', }); const SET = vi.fn(); store.on('set', SET); store.forceUpdate('str'); store.forceUpdate('str'); expect(SET).toHaveBeenCalledTimes(2); expect(SET).toBeCalledWith('str', 'hola', 'hola'); }); ================================================ FILE: src/observable-map.ts ================================================ import { OnHandler, OnChangeHandler, Subscription, ObservableMap, Handlers } from './types'; type Invocable = T | (() => T); const unwrap = (val: Invocable): T => (typeof val === 'function' ? (val as () => T)() : val); export const createObservableMap = ( defaultState?: Invocable, shouldUpdate: (newV: any, oldValue, prop: keyof T) => boolean = (a, b) => a !== b, ): ObservableMap => { const resolveDefaultState = (): T => (unwrap(defaultState) ?? {}) as T; const initialState = resolveDefaultState(); let states = new Map(Object.entries(initialState)); const proxyAvailable = typeof Proxy !== 'undefined'; const plainState: T | null = proxyAvailable ? null : ({} as T); const handlers: Handlers = { dispose: [], get: [], set: [], reset: [], }; // Track onChange listeners to enable removeListener functionality const changeListeners = new Map(); const reset = (): void => { // When resetting the state, the default state may be a function - unwrap it to invoke it. // otherwise, the state won't be properly reset states = new Map(Object.entries(resolveDefaultState())); if (!proxyAvailable) { syncPlainStateKeys(); } handlers.reset.forEach((cb) => cb()); }; const dispose = (): void => { // Call first dispose as resetting the state would // cause less updates ;) handlers.dispose.forEach((cb) => cb()); reset(); }; const get =

(propName: P & string): T[P] => { handlers.get.forEach((cb) => cb(propName)); return states.get(propName); }; const set =

(propName: P & string, value: T[P]) => { const oldValue = states.get(propName); if (shouldUpdate(value, oldValue, propName)) { states.set(propName, value); if (!proxyAvailable) { ensurePlainProperty(propName as string); } handlers.set.forEach((cb) => cb(propName, value, oldValue)); } }; const state = ( proxyAvailable ? new Proxy(initialState, { get(_, propName) { return get(propName as any); }, ownKeys(_) { return Array.from(states.keys()); }, getOwnPropertyDescriptor() { return { enumerable: true, configurable: true, }; }, has(_, propName) { return states.has(propName as any); }, set(_, propName, value) { set(propName as any, value); return true; }, }) : (() => { syncPlainStateKeys(); return plainState!; })() ) as T; const on: OnHandler = (eventName, callback) => { handlers[eventName].push(callback); return () => { removeFromArray(handlers[eventName], callback); }; }; const onChange: OnChangeHandler = (propName, cb) => { const setHandler = (key, newValue) => { if (key === propName) { cb(newValue); } }; const resetHandler = () => { const snapshot = resolveDefaultState(); cb(snapshot[propName]); }; // Register the handlers const unSet = on('set', setHandler); const unReset = on('reset', resetHandler); // Track the relationship between the user callback and internal handlers changeListeners.set(cb, { setHandler, resetHandler, propName }); return () => { unSet(); unReset(); changeListeners.delete(cb); }; }; const use = (...subscriptions: Subscription[]): (() => void) => { const unsubs = subscriptions.reduce((unsubs, subscription) => { if (subscription.set) { unsubs.push(on('set', subscription.set)); } if (subscription.get) { unsubs.push(on('get', subscription.get)); } if (subscription.reset) { unsubs.push(on('reset', subscription.reset)); } if (subscription.dispose) { unsubs.push(on('dispose', subscription.dispose)); } return unsubs; }, []); return () => unsubs.forEach((unsub) => unsub()); }; const forceUpdate = (key: string) => { const oldValue = states.get(key); handlers.set.forEach((cb) => cb(key, oldValue, oldValue)); }; const removeListener = (propName: keyof T, listener: (value: any) => void) => { const listenerInfo = changeListeners.get(listener); if (listenerInfo && listenerInfo.propName === propName) { // Remove the specific handlers that were created for this listener removeFromArray(handlers.set, listenerInfo.setHandler); removeFromArray(handlers.reset, listenerInfo.resetHandler); changeListeners.delete(listener); } }; function ensurePlainProperty(key: string): void { if (proxyAvailable || !plainState) { return; } if (Object.prototype.hasOwnProperty.call(plainState, key)) { return; } Object.defineProperty(plainState, key, { configurable: true, enumerable: true, get() { return get(key as keyof T & string); }, set(value: any) { set(key as keyof T & string, value); }, }); } function syncPlainStateKeys(): void { if (proxyAvailable || !plainState) { return; } const knownKeys = new Set(states.keys()); for (const key of Object.keys(plainState as object)) { if (!knownKeys.has(key)) { delete (plainState as Record)[key]; } } for (const key of knownKeys) { ensurePlainProperty(key); } } return { state, get, set, on, onChange, use, dispose, reset, forceUpdate, removeListener, }; }; const removeFromArray = (array: any[], item: any) => { const index = array.indexOf(item); if (index >= 0) { array[index] = array[array.length - 1]; array.length--; } }; ================================================ FILE: src/store.test.ts ================================================ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Mock } from 'vitest'; vi.mock('./observable-map', () => { return { createObservableMap: vi.fn(), }; }); vi.mock('./subscriptions/stencil', () => { return { stencilSubscription: vi.fn(), }; }); afterEach(() => { vi.clearAllMocks(); }); describe('createStore', () => { it('creates an observable map and wires the stencil subscription', async () => { const map = { use: vi.fn(), state: {} } as const; const subscription = { get: vi.fn() }; const { createObservableMap } = await import('./observable-map'); const { stencilSubscription } = await import('./subscriptions/stencil'); (createObservableMap as unknown as Mock).mockReturnValue(map as any); (stencilSubscription as unknown as Mock).mockReturnValue(subscription as any); const { createStore } = await import('./store'); const defaultState = { value: 1 }; const shouldUpdate = vi.fn(); const result = createStore(defaultState, shouldUpdate); expect(createObservableMap).toHaveBeenCalledWith(defaultState, shouldUpdate); expect(map.use).toHaveBeenCalledWith(subscription); expect(result).toBe(map); }); }); ================================================ FILE: src/store.ts ================================================ import { stencilSubscription } from './subscriptions/stencil'; import { createObservableMap } from './observable-map'; import { ObservableMap } from './types'; export const createStore = ( defaultState?: T | (() => T), shouldUpdate?: (newV: any, oldValue, prop: keyof T) => boolean, ): ObservableMap => { const map = createObservableMap(defaultState, shouldUpdate); map.use(stencilSubscription()); return map; }; ================================================ FILE: src/subscriptions/stencil.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const coreMock = vi.hoisted(() => ({ exports: {} as { forceUpdate?: ReturnType; getRenderingRef?: ReturnType; }, })); vi.mock('@stencil/core', () => coreMock.exports); describe('stencilSubscription', () => { beforeEach(() => { coreMock.exports.forceUpdate = undefined; coreMock.exports.getRenderingRef = undefined; }); afterEach(() => { vi.clearAllMocks(); vi.resetModules(); vi.useRealTimers(); }); it('returns an empty subscription when stencil internals are unavailable', async () => { coreMock.exports.forceUpdate = vi.fn(); coreMock.exports.getRenderingRef = undefined; const { stencilSubscription } = await import('./stencil'); expect(stencilSubscription()).toEqual({}); }); it('tracks stencil elements and triggers updates', async () => { vi.useFakeTimers(); const connectedElm = { isConnected: true, id: 'connected' }; const disconnectedElm = { isConnected: false, id: 'disconnected' }; const legacyElm = { id: 'legacy' } as { id: string; isConnected?: boolean }; const forceUpdate = vi.fn((elm: typeof connectedElm) => elm.isConnected !== false); const getRenderingRef = vi .fn() .mockReturnValueOnce(connectedElm) .mockReturnValueOnce(disconnectedElm) .mockReturnValueOnce(legacyElm) .mockReturnValueOnce(undefined); coreMock.exports.forceUpdate = forceUpdate as any; coreMock.exports.getRenderingRef = getRenderingRef as any; const { stencilSubscription } = await import('./stencil'); const subscription = stencilSubscription(); subscription.set?.('missing'); expect(forceUpdate).not.toHaveBeenCalled(); subscription.get?.('prop'); subscription.get?.('prop'); subscription.get?.('prop'); subscription.get?.('prop'); expect(getRenderingRef).toHaveBeenCalledTimes(4); subscription.set?.('prop'); expect(forceUpdate).toHaveBeenCalledTimes(3); expect(forceUpdate.mock.calls[0]?.[0]).toBe(connectedElm); expect(forceUpdate.mock.calls[1]?.[0]).toBe(disconnectedElm); expect(forceUpdate.mock.calls[2]?.[0]).toBe(legacyElm); vi.runAllTimers(); forceUpdate.mockClear(); subscription.reset?.(); expect(forceUpdate).toHaveBeenCalledTimes(2); expect(forceUpdate.mock.calls[0]?.[0]).toBe(connectedElm); expect(forceUpdate.mock.calls[1]?.[0]).toBe(legacyElm); vi.runAllTimers(); forceUpdate.mockClear(); subscription.dispose?.(); subscription.reset?.(); expect(forceUpdate).not.toHaveBeenCalled(); }); it('prevents duplicate subscriptions for the same element', async () => { const elm = { isConnected: true, id: 'unique' }; const forceUpdate = vi.fn(); const getRenderingRef = vi.fn().mockReturnValue(elm); coreMock.exports.forceUpdate = forceUpdate as any; coreMock.exports.getRenderingRef = getRenderingRef as any; const { stencilSubscription } = await import('./stencil'); const subscription = stencilSubscription(); subscription.get?.('prop'); subscription.get?.('prop'); expect(getRenderingRef).toHaveBeenCalledTimes(2); subscription.set?.('prop'); expect(forceUpdate).toHaveBeenCalledTimes(1); }); it('handles garbage collected elements', async () => { const originalWeakRef = global.WeakRef; const gcedElm = { id: 'gced' }; const keptElm = { id: 'kept', isConnected: true }; class MockWeakRef { target: any; constructor(target: any) { this.target = target; } deref() { if (this.target === gcedElm) return undefined; return this.target; } } (global as any).WeakRef = MockWeakRef; const forceUpdate = vi.fn(() => true); const getRenderingRef = vi.fn().mockReturnValueOnce(gcedElm).mockReturnValueOnce(keptElm); coreMock.exports.forceUpdate = forceUpdate as any; coreMock.exports.getRenderingRef = getRenderingRef as any; try { const { stencilSubscription } = await import('./stencil'); const subscription = stencilSubscription(); subscription.get?.('prop'); subscription.get?.('prop'); subscription.set?.('prop'); expect(forceUpdate).toHaveBeenCalledTimes(1); expect(forceUpdate).toHaveBeenCalledWith(keptElm); forceUpdate.mockClear(); subscription.reset?.(); expect(forceUpdate).toHaveBeenCalledTimes(1); expect(forceUpdate).toHaveBeenCalledWith(keptElm); } finally { global.WeakRef = originalWeakRef; } }); }); ================================================ FILE: src/subscriptions/stencil.ts ================================================ import * as StencilCore from '@stencil/core'; import { Subscription } from '../types'; import { appendToMap, debounce } from '../utils'; /** * Check if a possible element isConnected. * The property might not be there, so we check for it. * * We want it to return true if isConnected is not a property, * otherwise we would remove these elements and would not update. * * Better leak in Edge than to be useless. */ const isConnected = (maybeElement: any) => !('isConnected' in maybeElement) || maybeElement.isConnected; const cleanupElements = debounce((map: Map[]>) => { for (let key of map.keys()) { const refs = map.get(key).filter((ref) => { const elm = ref.deref(); return elm && isConnected(elm); }); map.set(key, refs); } }, 2_000); const core = StencilCore as unknown as { forceUpdate?: (elm: any) => boolean; getRenderingRef?: () => any; }; const forceUpdate = core.forceUpdate; const getRenderingRef = core.getRenderingRef; export const stencilSubscription = (): Subscription => { if (typeof getRenderingRef !== 'function' || typeof forceUpdate !== 'function') { // If we are not in a stencil project, we do nothing. // This function is not really exported by @stencil/core. return {}; } const ensureForceUpdate = forceUpdate; const ensureGetRenderingRef = getRenderingRef; const elmsToUpdate = new Map[]>(); return { dispose: () => elmsToUpdate.clear(), get: (propName) => { const elm = ensureGetRenderingRef(); if (elm) { appendToMap(elmsToUpdate, propName as string, elm); } }, set: (propName) => { const refs = elmsToUpdate.get(propName as string); if (refs) { const nextRefs = refs.filter((ref) => { const elm = ref.deref(); if (!elm) return false; return ensureForceUpdate(elm); }); elmsToUpdate.set(propName as string, nextRefs); } cleanupElements(elmsToUpdate); }, reset: () => { elmsToUpdate.forEach((refs) => { refs.forEach((ref) => { const elm = ref.deref(); if (elm) ensureForceUpdate(elm); }); }); cleanupElements(elmsToUpdate); }, }; }; ================================================ FILE: src/types.ts ================================================ export interface Handlers { dispose: DisposeEventHandler[]; get: GetEventHandler[]; reset: ResetEventHandler[]; set: SetEventHandler[]; } export type SetEventHandler = (key: keyof StoreType, newValue: any, oldValue: any) => void; export type GetEventHandler = (key: keyof StoreType) => void; export type ResetEventHandler = () => void; export type DisposeEventHandler = () => void; export interface OnHandler { (eventName: 'set', callback: SetEventHandler): () => void; (eventName: 'get', callback: GetEventHandler): () => void; (eventName: 'dispose', callback: DisposeEventHandler): () => void; (eventName: 'reset', callback: ResetEventHandler): () => void; } export interface OnChangeHandler { (propName: Key, cb: (newValue: StoreType[Key]) => void): () => void; } export interface Subscription { dispose?(): void; get?(key: KeyFromStoreType): void; set?( key: KeyFromStoreType, newValue: StoreType[KeyFromStoreType], oldValue: StoreType[KeyFromStoreType], ): void; reset?(): void; } export interface Getter {

(propName: P & string): T[P]; } export interface Setter {

(propName: P & string, value: T[P]): void; } export interface ObservableMap { /** * Proxied object that will detect dependencies and call * the subscriptions and computed properties. * * If available, it will detect from which Stencil Component * it was called and rerender it when the property changes. * * Note: Proxy objects are not supported by IE11 (not even with a polyfill) * so you need to use the store.get and store.set methods of the API if you wish to support IE11. * * @returns The proxied object */ state: T; /** * Only useful if you need to support IE11. * * @param propName - The property name to get * @returns The value of the property * * @example * const { state, get } = createStore({ hola: 'hello', adios: 'goodbye' }); * console.log(state.hola); // If you don't need to support IE11, use this way. * console.log(get('hola')); // If you need to support IE11, use this other way. */ get: Getter; /** * Only useful if you need to support IE11. * * @param propName - The property name to set * @param value - The value to set * @returns void * * @example * const { state, get } = createStore({ hola: 'hello', adios: 'goodbye' }); * state.hola = 'ola'; // If you don't need to support IE11, use this way. * set('hola', 'ola')); // If you need to support IE11, use this other way. */ set: Setter; /** * Register a event listener, you can listen to `set`, `get` and `reset` events. * * @param eventName - The event name to listen for * @param callback - The callback to call when the event occurs * @returns A function to unsubscribe from the listener * * @example * store.on('set', (prop, value) => { * console.log(`Prop ${prop} changed to: ${value}`); * }); */ on: OnHandler; /** * Easily listen for value changes of the specified key. * * @param propName - The property name to listen for * @param cb - The callback to call when the property changes * @returns A function to unsubscribe from the listener */ onChange: OnChangeHandler; /** * Resets the state to its original state and * signals a dispose event to all the plugins. * * This method is intended for plugins to reset * all their internal state between tests. * * @returns void */ dispose(): void; /** * Resets the state to its original state. * * @returns void */ reset(): void; /** * Registers a subscription that will be called whenever the user gets, sets, or * resets a value. * * @param plugins - The plugins to use * @returns A function to unsubscribe from the plugins */ use(...plugins: Subscription[]): () => void; /** * Force a rerender of the specified key, just like the value changed. * * @param key - The property name to force an update for * @returns void */ forceUpdate(key: keyof T): void; /** * Remove a listener * * @param propName - The property name to remove the listener from * @param listener - The listener to remove * @returns void */ removeListener(propName: keyof T, listener: (value: any) => void): void; } ================================================ FILE: src/utils.test.ts ================================================ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { appendToMap, debounce } from './utils'; describe('appendToMap', () => { let testMap: Map[]>; beforeEach(() => { testMap = new Map(); }); it('should add value to empty map', () => { const obj = { id: 1 }; appendToMap(testMap, 'key1', obj); const refs = testMap.get('key1'); expect(refs).toHaveLength(1); expect(refs![0].deref()).toBe(obj); }); it('should append value to existing array', () => { const obj1 = { id: 1 }; const obj2 = { id: 3 }; appendToMap(testMap, 'key1', obj1); appendToMap(testMap, 'key1', obj2); const refs = testMap.get('key1'); expect(refs).toHaveLength(2); expect(refs![0].deref()).toBe(obj1); expect(refs![1].deref()).toBe(obj2); }); it('should not append duplicate value', () => { const obj1 = { id: 1 }; const obj2 = { id: 2 }; appendToMap(testMap, 'key1', obj1); appendToMap(testMap, 'key1', obj2); appendToMap(testMap, 'key1', obj1); // Duplicate const refs = testMap.get('key1'); expect(refs).toHaveLength(2); expect(refs![0].deref()).toBe(obj1); expect(refs![1].deref()).toBe(obj2); }); }); describe('debounce', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.restoreAllMocks(); }); it('should debounce function calls', () => { const mockFn = vi.fn(); const debouncedFn = debounce(mockFn, 1000); // Call the debounced function multiple times debouncedFn(1); debouncedFn(2); debouncedFn(3); // Function should not have been called yet expect(mockFn).not.toHaveBeenCalled(); // Fast forward time vi.runAllTimers(); // Function should have been called once with the last arguments expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith(3); }); it('should cancel previous timeout on new calls', () => { const mockFn = vi.fn(); const debouncedFn = debounce(mockFn, 1000); debouncedFn(1); // Advance timer halfway vi.advanceTimersByTime(500); debouncedFn(2); // Advance to just before the second call would trigger vi.advanceTimersByTime(999); expect(mockFn).not.toHaveBeenCalled(); // Advance the remaining time vi.advanceTimersByTime(1); expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith(2); }); }); ================================================ FILE: src/utils.ts ================================================ export const appendToMap = (map: Map[]>, propName: K, value: V) => { let refs = map.get(propName); if (!refs) { refs = []; map.set(propName, refs); } if (!refs.some((ref) => ref.deref() === value)) { refs.push(new WeakRef(value)); } }; export const debounce = any>(fn: T, ms: number): ((...args: Parameters) => void) => { let timeoutId: any; return (...args: Parameters) => { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => { timeoutId = 0; fn(...args); }, ms); }; }; ================================================ FILE: test-app/.editorconfig ================================================ # http://editorconfig.org root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.md] insert_final_newline = false trim_trailing_whitespace = false ================================================ FILE: test-app/.gitignore ================================================ dist/ www/ loader/ *~ *.sw[mnpcod] *.log *.lock *.tmp *.tmp.* log.txt *.sublime-project *.sublime-workspace .stencil/ .idea/ .vscode/ .sass-cache/ .versions/ node_modules/ $RECYCLE.BIN/ .DS_Store Thumbs.db UserInterfaceState.xcuserstate .env ================================================ FILE: test-app/.prettierrc.json ================================================ { "parser": "typescript", "printWidth": 100, "semi": true, "singleQuote": true, "trailingComma": "es5", "jsxBracketSameLine": false, "jsxSingleQuote": false } ================================================ FILE: test-app/LICENSE ================================================ MIT License Copyright (c) 2018 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: test-app/package.json ================================================ { "name": "stencil-store-tests", "version": "0.0.1", "description": "Stencil Component Starter", "main": "./dist/index.cjs.js", "module": "./dist/index.js", "es2015": "dist/esm/index.mjs", "es2017": "dist/esm/index.mjs", "types": "dist/types/components.d.ts", "collection": "dist/collection/collection-manifest.json", "collection:main": "dist/collection/index.js", "unpkg": "dist/stencil-store-tests/stencil-store-tests.js", "files": [ "dist/", "loader/" ], "scripts": { "build": "stencil build", "start": "stencil build --dev --watch --serve", "lint": "npm run lint.prettier", "test": "stencil test --spec --e2e", "test.spec": "stencil test --spec", "test.ci": "npm run test", "test.watch": "stencil test --spec --e2e --watchAll", "generate": "stencil generate" }, "devDependencies": { "@stencil/core": "^4.0.0", "@stencil/store": "latest", "@types/node": "^20.11.7", "puppeteer": "^22.15.0" }, "license": "MIT" } ================================================ FILE: test-app/readme.md ================================================ ![Built With Stencil](https://img.shields.io/badge/-Built%20With%20Stencil-16161d.svg?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjIuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA1MTIgNTEyOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI%2BCjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI%2BCgkuc3Qwe2ZpbGw6I0ZGRkZGRjt9Cjwvc3R5bGU%2BCjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik00MjQuNywzNzMuOWMwLDM3LjYtNTUuMSw2OC42LTkyLjcsNjguNkgxODAuNGMtMzcuOSwwLTkyLjctMzAuNy05Mi43LTY4LjZ2LTMuNmgzMzYuOVYzNzMuOXoiLz4KPHBhdGggY2xhc3M9InN0MCIgZD0iTTQyNC43LDI5Mi4xSDE4MC40Yy0zNy42LDAtOTIuNy0zMS05Mi43LTY4LjZ2LTMuNkgzMzJjMzcuNiwwLDkyLjcsMzEsOTIuNyw2OC42VjI5Mi4xeiIvPgo8cGF0aCBjbGFzcz0ic3QwIiBkPSJNNDI0LjcsMTQxLjdIODcuN3YtMy42YzAtMzcuNiw1NC44LTY4LjYsOTIuNy02OC42SDMzMmMzNy45LDAsOTIuNywzMC43LDkyLjcsNjguNlYxNDEuN3oiLz4KPC9zdmc%2BCg%3D%3D&colorA=16161d&style=flat-square) # Stencil Component Starter > This is a starter project for building a standalone Web Component using Stencil. Stencil is a compiler for building fast web apps using Web Components. Stencil combines the best concepts of the most popular frontend frameworks into a compile-time rather than run-time tool. Stencil takes TypeScript, JSX, a tiny virtual DOM layer, efficient one-way data binding, an asynchronous rendering pipeline (similar to React Fiber), and lazy-loading out of the box, and generates 100% standards-based Web Components that run in any browser supporting the Custom Elements v1 spec. Stencil components are just Web Components, so they work in any major framework or with no framework at all. ## Getting Started To start building a new web component using Stencil, clone this repo to a new directory: ```bash git clone https://github.com/stenciljs/component-starter.git my-component cd my-component git remote rm origin ``` and run: ```bash npm install npm start ``` To build the component for production, run: ```bash npm run build ``` To run the unit tests for the components, run: ```bash npm test ``` Need help? Check out our docs [here](https://stenciljs.com/docs/my-first-component). ## Naming Components When creating new component tags, we recommend _not_ using `stencil` in the component name (ex: ``). This is because the generated component has little to nothing to do with Stencil; it's just a web component! Instead, use a prefix that fits your company or any name for a group of related components. For example, all of the Ionic generated web components use the prefix `ion`. ## Using this component ### Script tag - [Publish to NPM](https://docs.npmjs.com/getting-started/publishing-npm-packages) - Put a script tag similar to this `` in the head of your index.html - Then you can use the element anywhere in your template, JSX, html etc ### Node Modules - Run `npm install my-component --save` - Put a script tag similar to this `` in the head of your index.html - Then you can use the element anywhere in your template, JSX, html etc ### In a stencil-starter app - Run `npm install my-component --save` - Add an import to the npm packages `import my-component;` - Then you can use the element anywhere in your template, JSX, html etc ================================================ FILE: test-app/src/components/change-store/change-store.tsx ================================================ import { Component, Prop, Host, h } from '@stencil/core'; import { state } from '../../utils/greeting-store'; @Component({ tag: 'change-store', shadow: false, }) export class ChangeStore { @Prop() storeKey: 'hola' | 'adios'; @Prop() storeValue: string; changeValue() { state[this.storeKey] = this.storeValue; } render() { return ( ); } } ================================================ FILE: test-app/src/components/display-store/display-store.e2e.ts ================================================ import { newE2EPage, E2EPage } from '@stencil/core/testing'; describe('display-store', () => { it('re-renders', async () => { const page = await newE2EPage(); await page.setContent( '' ); expect(await displayedValue(page)).toEqual('hola'); expect(await renderCount(page)).toEqual(1); await changeValue(page); expect(await displayedValue(page)).toEqual('other-value'); expect(await renderCount(page)).toEqual(2); }); it('does not rerender if the key changed is a different one', async () => { const page = await newE2EPage(); await page.setContent( '' ); expect(await displayedValue(page, '.hello')).toEqual('hola'); expect(await renderCount(page, '.hello')).toEqual(1); expect(await displayedValue(page, '.goodbye')).toEqual('adiós'); expect(await renderCount(page, '.goodbye')).toEqual(1); await changeValue(page); expect(await displayedValue(page, '.hello')).toEqual('other-value'); expect(await renderCount(page, '.hello')).toEqual(2); expect(await displayedValue(page, '.goodbye')).toEqual('adiós'); expect(await renderCount(page, '.goodbye')).toEqual(1); }); }); const displayedValue = async (page: E2EPage, parentSelector?: string): Promise => { const parentElement = parentSelector === undefined ? page : await page.find(parentSelector); const element = await parentElement.find('.value'); return element.textContent; }; const renderCount = async (page: E2EPage, parentSelector?: string): Promise => { const parentElement = parentSelector === undefined ? page : await page.find(parentSelector); const element = await parentElement.find('.counter'); return parseInt(element.textContent, 10); }; const changeValue = async (page: E2EPage, newValue?: string): Promise => { if (newValue !== undefined) { const element = await page.find('change-store'); element.setProperty('store-value', newValue); } const button = await page.find('button'); await button.click(); await page.waitForChanges(); }; ================================================ FILE: test-app/src/components/display-store/display-store.tsx ================================================ import { Component, Prop, h, Host } from '@stencil/core'; import { state } from '../../utils/greeting-store'; @Component({ tag: 'display-store', shadow: false, }) export class DisplayStore { @Prop() storeKey: 'hello' | 'goodbye'; i = 0; render() { this.i++; return ( {this.i} {state[this.storeKey]} ); } } ================================================ FILE: test-app/src/components/simple-store/display-store.spec.ts ================================================ import { newSpecPage } from '@stencil/core/testing'; import { SimpleStore } from './display-store'; import { dispose, reset } from '../../utils/greeting-store'; describe('some-store', () => { beforeEach(() => dispose()); it('updates', async () => { reset(); const { root, waitForChanges } = await newSpecPage({ components: [SimpleStore], html: ``, }); expect(root).toEqualHtml(` hola 0 0 `); await root.next(); await waitForChanges(); expect(root).toEqualHtml(` hola 1 1 `); await root.next(); await waitForChanges(); expect(root).toEqualHtml(` hola 2 4 `); reset(); await waitForChanges(); expect(root).toEqualHtml(` hola 0 0 `); }); it('resetting in a second test does not crash', async () => { reset(); const { root, waitForChanges } = await newSpecPage({ components: [SimpleStore], html: ``, }); expect(root).toEqualHtml(` hola 0 0 `); await root.next(); await waitForChanges(); expect(root).toEqualHtml(` hola 1 1 `); await root.next(); await waitForChanges(); expect(root).toEqualHtml(` hola 2 4 `); reset(); await waitForChanges(); expect(root).toEqualHtml(` hola 0 0 `); }); }); ================================================ FILE: test-app/src/components/simple-store/display-store.tsx ================================================ import { Component, h, Host, Method } from '@stencil/core'; import { state } from '../../utils/greeting-store'; @Component({ tag: 'simple-store', shadow: false, }) export class SimpleStore { @Method() async next() { state.clicks++; } render() { return ( {state.hello} {state.clicks} {state.squaredClicks} ); } } ================================================ FILE: test-app/src/components.d.ts ================================================ /* eslint-disable */ /* tslint:disable */ /** * This is an autogenerated file created by the Stencil compiler. * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; export namespace Components { interface ChangeStore { "storeKey": 'hola' | 'adios'; "storeValue": string; } interface DisplayStore { "storeKey": 'hello' | 'goodbye'; } interface SimpleStore { "next": () => Promise; } } declare global { interface HTMLChangeStoreElement extends Components.ChangeStore, HTMLStencilElement { } var HTMLChangeStoreElement: { prototype: HTMLChangeStoreElement; new (): HTMLChangeStoreElement; }; interface HTMLDisplayStoreElement extends Components.DisplayStore, HTMLStencilElement { } var HTMLDisplayStoreElement: { prototype: HTMLDisplayStoreElement; new (): HTMLDisplayStoreElement; }; interface HTMLSimpleStoreElement extends Components.SimpleStore, HTMLStencilElement { } var HTMLSimpleStoreElement: { prototype: HTMLSimpleStoreElement; new (): HTMLSimpleStoreElement; }; interface HTMLElementTagNameMap { "change-store": HTMLChangeStoreElement; "display-store": HTMLDisplayStoreElement; "simple-store": HTMLSimpleStoreElement; } } declare namespace LocalJSX { interface ChangeStore { "storeKey"?: 'hola' | 'adios'; "storeValue"?: string; } interface DisplayStore { "storeKey"?: 'hello' | 'goodbye'; } interface SimpleStore { } interface IntrinsicElements { "change-store": ChangeStore; "display-store": DisplayStore; "simple-store": SimpleStore; } } export { LocalJSX as JSX }; declare module "@stencil/core" { export namespace JSX { interface IntrinsicElements { "change-store": LocalJSX.ChangeStore & JSXBase.HTMLAttributes; "display-store": LocalJSX.DisplayStore & JSXBase.HTMLAttributes; "simple-store": LocalJSX.SimpleStore & JSXBase.HTMLAttributes; } } } ================================================ FILE: test-app/src/index.html ================================================ Stencil Component Starter ================================================ FILE: test-app/src/index.ts ================================================ export * from './components'; ================================================ FILE: test-app/src/utils/greeting-store.ts ================================================ import { createStore } from '@stencil/store'; const store = createStore({ hello: 'hola', goodbye: 'adiós', clicks: 0, squaredClicks: 0, }); store.onChange('clicks', (value) => { state.squaredClicks = value ** 2; }); export const dispose = store.dispose; export const state = store.state; export const reset = store.reset; ================================================ FILE: test-app/stencil.config.ts ================================================ import { Config } from '@stencil/core'; export const config: Config = { namespace: 'stencil-store-tests', outputTargets: [ { type: 'dist', esmLoaderPath: '../loader' }, { type: 'www', serviceWorker: null // disable service workers } ] }; ================================================ FILE: test-app/tsconfig.json ================================================ { "compilerOptions": { "allowSyntheticDefaultImports": true, "allowUnreachableCode": false, "declaration": false, "experimentalDecorators": true, "lib": [ "dom", "es2017" ], "moduleResolution": "node", "module": "esnext", "target": "es2017", "noUnusedLocals": true, "noUnusedParameters": true, "jsx": "react", "jsxFactory": "h" }, "include": [ "src", "types/jsx.d.ts" ], "exclude": [ "node_modules" ] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "allowSyntheticDefaultImports": true, "allowUnreachableCode": false, "declaration": true, "experimentalDecorators": true, "lib": [ "dom", "es2022" ], "outDir": "build", "declarationDir": "dist", "moduleResolution": "bundler", "module": "esnext", "target": "es2022", "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "noUnusedParameters": true, "jsx": "react", "jsxFactory": "h", "useUnknownInCatchVariables": true }, "files": [ "src/index.ts" ], "exclude": [ "node_modules", "src/**/*.test.ts" ] } ================================================ FILE: vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; const exclude = ['node_modules', 'dist', 'test-app']; export default defineConfig({ test: { exclude, coverage: { enabled: true, exclude: [ ...exclude, 'vitest.config.ts', 'rollup.config.js' ], provider: 'v8', thresholds: { branches: 93, functions: 86, lines: 80, statements: 80, }, }, }, });