Skip to content

Commit 5359ee1

Browse files
committed
feat: adds loadScript
1 parent 24b056d commit 5359ee1

File tree

11 files changed

+477
-149
lines changed

11 files changed

+477
-149
lines changed

README.md

Lines changed: 68 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,86 @@
1-
<p>
2-
<img width="100%" src="https://assets.solidjs.com/banner?type=Ecosystem&background=tiles&project=library-name" alt="solid-create-script">
3-
</p>
1+
[![NPM Version](https://img.shields.io/npm/v/@dschz/load-script.svg?style=for-the-badge)](https://www.npmjs.com/package/@dschz/load-script)
2+
[![Build Status](https://img.shields.io/github/actions/workflow/status/dsnchz/load-script/ci.yaml?branch=main&logo=github&style=for-the-badge)](https://github.com/dsnchz/load-script/actions/workflows/ci.yaml)
3+
[![bun](https://img.shields.io/badge/maintained%20with-bun-cc00ff.svg?style=for-the-badge&logo=bun)](https://bun.sh/)
44

5-
# Template: SolidJS Library
5+
# @dschz/load-script
66

7-
Template for [SolidJS](https://www.solidjs.com/) library package. Bundling of the library is managed by [tsup](https://tsup.egoist.dev/).
7+
> Lightweight utility for dynamically loading external scripts into the browser — framework-agnostic, caching-safe, and CSP-friendly.
88
9-
Other things configured include:
9+
## 📦 Installation
1010

11-
- Bun (for dependency management and running scripts)
12-
- TypeScript
13-
- ESLint / Prettier
14-
- Solid Testing Library + Vitest (for testing)
15-
- Playground app using library
16-
- GitHub Actions (for all CI/CD)
11+
```bash
12+
npm install @dschz/load-script
13+
pnpm install @dschz/load-script
14+
yarn install @dschz/load-script
15+
bun install @dschz/load-script
16+
```
1717

18-
## Getting Started
18+
## 🔧 Usage
1919

20-
Some pre-requisites before install dependencies:
20+
```ts
21+
import { loadScript } from "@dschz/load-script";
2122

22-
- Install Node Version Manager (NVM)
23-
```bash
24-
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
25-
```
26-
- Install Bun
27-
```bash
28-
curl -fsSL https://bun.sh/install | bash
29-
```
23+
await loadScript("https://example.com/library.js", {
24+
async: true,
25+
type: "text/javascript",
26+
});
27+
```
3028

31-
### Installing Dependencies
29+
## 🧠 API
3230

33-
```bash
34-
nvm use
35-
bun install
36-
```
31+
### `loadScript(src, attributes?, target?)`
3732

38-
### Local Development Build
33+
Loads an external script dynamically and returns a `Promise<HTMLScriptElement>`.
3934

40-
```bash
41-
bun start
42-
```
35+
#### Parameters:
4336

44-
### Linting & Formatting
37+
| Name | Type | Description |
38+
| -------- | ------------------------ | ----------------------------------------------------- |
39+
| `src` | `string` | Script URL (required) |
40+
| `props` | `HTMLScriptElementProps` | Script tag instance properties (e.g. `async`, `type`) |
41+
| `target` | `HTMLElement` | Target element to append to (default: `head`) |
4542

46-
```bash
47-
bun run lint # checks source for lint violations
48-
bun run format # checks source for format violations
43+
---
44+
45+
## ✅ Features
4946

50-
bun run lint:fix # fixes lint violations
51-
bun run format:fix # fixes format violations
47+
- 📑 Fully typed with TypeScript for autocompletion and safety
48+
- 🚫 Prevents duplicate script injection via internal cache
49+
- ⚙️ Supports `data-*`, `nonce`, `fetchPriority`, `crossOrigin`, and other modern attributes
50+
- 📆 Small and framework-agnostic
51+
- 🔒 CSP-friendly via `nonce` support
52+
53+
---
54+
55+
## 🧪 Example: With SolidJS
56+
57+
```ts
58+
import { onMount } from "solid-js";
59+
import { loadScript } from "@dschz/load-script";
60+
61+
const WidgetLoader = () => {
62+
let containerRef!: HTMLElement;
63+
64+
onMount(async () => {
65+
await loadScript("https://example.com/widget.js", {
66+
type: "text/javascript",
67+
async: true,
68+
}, containerRef);
69+
});
70+
71+
return <div ref={containerRef} />;
72+
};
5273
```
5374

54-
### Contributing
75+
---
76+
77+
## 📝 Notes
78+
79+
- Scripts are cached by `src` unless `innerHTML` or `textContent` is used
80+
- Cleanup is not automatic — script elements remain in the DOM
81+
82+
---
5583

56-
The only requirements when contributing are:
84+
## 💬 Feedback & Contributions
5785

58-
- You keep a clean git history in your branch
59-
- rebasing `main` instead of making merge commits.
60-
- Using proper commit message formats that adhere to [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)
61-
- Additionally, squashing (via rebase) commits that are not [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)
62-
- CI checks pass before merging into `main`
86+
Feel free to open [issues](https://github.com/dsnchz/load-script/issues) or submit pull requests. PRs are welcome!

bun.lock

Lines changed: 2 additions & 56 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

jsr.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"name": "template-solidjs-library",
3-
"version": "0.0.0",
2+
"name": "@dschz/load-script",
3+
"version": "0.1.0",
44
"license": "MIT",
55
"exports": "./src/index.tsx"
66
}

package.json

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
2-
"name": "template-solidjs-library",
3-
"version": "0.0.0",
4-
"description": "Template for SolidJS library using tsup for bundling. Configured with Bun, NVM, TypeScript, ESLint, Prettier, Vitest, and GHA",
2+
"name": "@dschz/load-script",
3+
"version": "0.1.0",
4+
"description": "Dynamically load scripts in the browser with caching.",
55
"type": "module",
66
"author": "Daniel Sanchez <dsanc89@icloud.com>",
77
"license": "MIT",
8-
"homepage": "https://github.com/thedanchez/template-solidjs-library#readme",
8+
"homepage": "https://github.com/dsnchz/load-script#readme",
99
"bugs": {
10-
"url": "https://github.com/thedanchez/template-solidjs-library/issues"
10+
"url": "https://github.com/dsnchz/load-script/issues"
1111
},
1212
"files": [
1313
"dist"
@@ -43,9 +43,7 @@
4343
},
4444
"devDependencies": {
4545
"@changesets/cli": "^2.29.3",
46-
"@solidjs/testing-library": "^0.8.10",
4746
"@tailwindcss/vite": "^4.1.5",
48-
"@testing-library/jest-dom": "^6.6.3",
4947
"@types/bun": "^1.2.12",
5048
"@typescript-eslint/eslint-plugin": "^8.32.0",
5149
"@typescript-eslint/parser": "^8.32.0",
@@ -59,14 +57,10 @@
5957
"prettier": "^3.5.3",
6058
"tailwindcss": "^4.1.5",
6159
"tsup": "^8.4.0",
62-
"tsup-preset-solid": "^2.2.0",
6360
"typescript": "^5.8.3",
6461
"typescript-eslint": "^8.32.0",
6562
"vite": "^6.3.5",
6663
"vite-plugin-solid": "^2.11.6",
6764
"vitest": "^3.1.3"
68-
},
69-
"peerDependencies": {
70-
"solid-js": ">=1.6.0"
7165
}
7266
}

src/__tests__/loadScript.test.tsx

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { beforeEach, describe, expect, test, vi } from "vitest";
2+
3+
import { __resetScriptCache, type HTMLScriptElementProps, loadScript } from "../loadScript";
4+
5+
const SCRIPT_SRC = "https://example.com/script.js";
6+
7+
describe("loadScript", () => {
8+
beforeEach(() => {
9+
document.querySelectorAll("script").forEach((s) => s.remove());
10+
__resetScriptCache();
11+
});
12+
13+
test("rejects if src is not provided", async () => {
14+
await expect(loadScript("")).rejects.toThrow('No "src" provided for createScript');
15+
});
16+
17+
test("resolves immediately if script already exists in DOM", async () => {
18+
const script = document.createElement("script");
19+
script.src = SCRIPT_SRC;
20+
document.head.appendChild(script);
21+
22+
const resolved = await loadScript(SCRIPT_SRC);
23+
expect(resolved).toBe(script);
24+
});
25+
26+
test("injects script only once per src when innerHTML is not set", async () => {
27+
const promise1 = loadScript(SCRIPT_SRC, { async: true });
28+
const promise2 = loadScript(SCRIPT_SRC, { async: true });
29+
30+
const scripts = document.querySelectorAll(`script[src='${SCRIPT_SRC}']`);
31+
expect(scripts.length).toBe(1);
32+
33+
scripts[0]?.dispatchEvent(new Event("load"));
34+
35+
const s1 = await promise1;
36+
const s2 = await promise2;
37+
38+
expect(s1).toBe(s2);
39+
});
40+
41+
test("injects script multiple times when different innerHTML is set", async () => {
42+
const promise1 = loadScript(SCRIPT_SRC, {
43+
innerHTML: `{ "symbol": "AAPL" }`,
44+
});
45+
const promise2 = loadScript(SCRIPT_SRC, {
46+
innerHTML: `{ "symbol": "TSLA" }`,
47+
});
48+
49+
const scripts = document.querySelectorAll(`script[src='${SCRIPT_SRC}']`);
50+
expect(scripts.length).toBe(2);
51+
52+
scripts[0]?.dispatchEvent(new Event("load"));
53+
scripts[1]?.dispatchEvent(new Event("load"));
54+
55+
const s1 = await promise1;
56+
const s2 = await promise2;
57+
58+
expect(s1).not.toBe(s2);
59+
});
60+
61+
test("evicts stale cache when script was removed from DOM", async () => {
62+
const promise1 = loadScript(SCRIPT_SRC);
63+
const script1 = document.querySelector(`script[src="${SCRIPT_SRC}"]`)!;
64+
script1.dispatchEvent(new Event("load"));
65+
66+
const resolved1 = await promise1;
67+
expect(resolved1).toBe(script1);
68+
69+
// Simulate user removing the script from the DOM
70+
script1.remove();
71+
72+
// Second load: should not return the same script, should re-inject
73+
const promise2 = loadScript(SCRIPT_SRC);
74+
const script2 = document.querySelector(`script[src="${SCRIPT_SRC}"]`)!;
75+
76+
// Manually resolve the second injected script
77+
script2.dispatchEvent(new Event("load"));
78+
const resolved2 = await promise2;
79+
80+
expect(resolved2).not.toBe(resolved1);
81+
});
82+
83+
test("assigns all direct script properties correctly", async () => {
84+
const props = {
85+
async: false,
86+
defer: true,
87+
fetchPriority: "low",
88+
noModule: true,
89+
id: "test-script",
90+
type: "text/javascript",
91+
crossOrigin: "anonymous",
92+
referrerPolicy: "origin",
93+
integrity: "sha384-abc123",
94+
nonce: "xyz123",
95+
textContent: "Hello, world!",
96+
} as HTMLScriptElementProps;
97+
98+
const promise = loadScript(SCRIPT_SRC, props);
99+
100+
const [script] = document.querySelectorAll(`script[src="${SCRIPT_SRC}"]`);
101+
expect(script).toBeDefined();
102+
103+
script?.dispatchEvent(new Event("load"));
104+
const resolved = await promise;
105+
106+
expect(resolved.async).toBe(false);
107+
expect(resolved.id).toBe("test-script");
108+
expect(resolved.defer).toBe(true);
109+
expect(resolved.fetchPriority).toBe("low");
110+
expect(resolved.noModule).toBe(true);
111+
expect(resolved.type).toBe("text/javascript");
112+
expect(resolved.crossOrigin).toBe("anonymous");
113+
expect(resolved.referrerPolicy).toBe("origin");
114+
expect(resolved.integrity).toBe("sha384-abc123");
115+
expect(resolved.nonce).toBe("xyz123");
116+
expect(resolved.textContent).toBe("Hello, world!");
117+
});
118+
119+
test("applies additional script attributes via setAttribute", async () => {
120+
const promise = loadScript(SCRIPT_SRC, { "data-id": "custom-script-id" });
121+
122+
const [script] = document.querySelectorAll(`script[src="${SCRIPT_SRC}"]`);
123+
expect(script).toBeDefined();
124+
125+
script?.dispatchEvent(new Event("load"));
126+
const resolved = await promise;
127+
128+
expect(resolved.getAttribute("data-id")).toBe("custom-script-id");
129+
});
130+
131+
test("resolves when script loads", async () => {
132+
const promise = loadScript(SCRIPT_SRC);
133+
const script = document.querySelector(`script[src="${SCRIPT_SRC}"]`)!;
134+
135+
script.dispatchEvent(new Event("load"));
136+
137+
const resolved = await promise;
138+
expect(resolved).toBe(script);
139+
});
140+
141+
test("rejects when script fails to load", async () => {
142+
const promise = loadScript(SCRIPT_SRC);
143+
const script = document.querySelector(`script[src="${SCRIPT_SRC}"]`)!;
144+
145+
const errorEvent = new Event("error");
146+
147+
script.dispatchEvent(errorEvent);
148+
149+
await expect(promise).rejects.toBe(errorEvent);
150+
});
151+
152+
test("calls onLoad handler when provided after script loads", async () => {
153+
const onLoad = vi.fn();
154+
155+
const promise = loadScript(SCRIPT_SRC, { onLoad });
156+
const script = document.querySelector(`script[src="${SCRIPT_SRC}"]`)!;
157+
158+
script.dispatchEvent(new Event("load"));
159+
160+
const resolved = await promise;
161+
162+
expect(onLoad).toHaveBeenCalledOnce();
163+
expect(resolved).toBe(script);
164+
});
165+
166+
test("calls onError handler when script fails to load", async () => {
167+
const onError = vi.fn();
168+
169+
const promise = loadScript(SCRIPT_SRC, { onError });
170+
171+
const script = document.querySelector(`script[src="${SCRIPT_SRC}"]`)!;
172+
173+
const errorEvent = new Event("error");
174+
175+
script.dispatchEvent(errorEvent);
176+
177+
await expect(promise).rejects.toBe(errorEvent);
178+
179+
expect(onError).toHaveBeenCalledOnce();
180+
});
181+
});

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { HTMLScriptElementProps } from "./loadScript";
2+
export { loadScript } from "./loadScript";

src/index.tsx

Lines changed: 0 additions & 2 deletions
This file was deleted.

0 commit comments

Comments
 (0)