Skip to content
This repository was archived by the owner on Oct 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
name: Publish Extension
on:
workflow_dispatch:
push:
branches: main
paths:
- "src/**"
- "README.md"
- "package.json"
- ".github/workflows/main.yaml"

env:
GITHUB_TOKEN: ${{ secrets.ROAMJS_RELEASE_TOKEN }}
Expand Down
31 changes: 18 additions & 13 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
name: Publish Extension
name: Validate extension
on: pull_request

env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ vars.AWS_REGION }}

permissions:
contents: read
jobs:
deploy:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: install
run: npm install
- name: build
run: npx samepage build
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
- run: npm run typecheck
- run: npm run format:check
- run: npm run build:roam
- uses: actions/upload-artifact@v4
with:
name: dev-on-load
path: dist/
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ out
.env
extension.js
extension.js.LICENSE.txt

local/
.env.*
coverage/
.roamjs-load-extension/
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.0] - 2026-10-09

### Added

- **Dev on Load** - Reload developer extensions once after a configurable delay in seconds, including decimal values.
- **Custom notification** - Choose whether to show a completion toast, its intent, and its message in the settings panel.
- **Reload loop protection** - Avoid repeated reloads when this extension reloads itself, and cancel pending work when disabled.
57 changes: 12 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,50 +1,17 @@
# RoamJS Extension Base
# Dev on Load

Stock base for [RoamJS](https://roamjs.com) Roam Research extensions. **Fork this repo** to start a new extension.
<a href="https://roamjs.com/">
<img src="https://avatars.githubusercontent.com/u/138642184" alt="RoamJS Logo" title="RoamJS" align="right" height="60" />
</a>

## What's included
**Start your development session with freshly loaded extensions. Dev on Load automatically reloads all developer extensions after a delay you choose, with an optional custom notification.**

- **roamjs-components** — shared utilities, DOM helpers, queries, writes, and UI components
- **Samepage build** — `samepage build` produces the Roam Depot–ready bundle
- **Settings panel** — example `extensionAPI.settings.panel.create` with an Enable switch
- **TypeScript** — tsconfig extending `@samepage/scripts`
- **CI** — GitHub Actions to build on push/PR (uses RoamJS secrets for publish)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/RoamJS/dev-on-load)
[![Slack](https://img.shields.io/badge/Slack-%23roam--js-purple)](https://roamresearch.slack.com/archives/C016N2B66JU)

## After forking
## Features

1. **Rename the repo** and update `package.json`:
- `name`: your extension slug (e.g. `my-extension`)
- `description`: one line describing the extension

2. **Implement in `src/index.ts`**:
- Keep or replace the settings panel
- Add your logic using `roamjs-components` (e.g. `createHTMLObserver`, `createBlock`, `renderToast`)
- Return `{ unload }` to clean up on unload

3. **Update all instances of `{Extension Name}` to the name of the extension.**

### Rules for Agents updating this README.md

- refer to README-TEMPLATE.md for the structure of the README.md
- This is a marketing document. It is user facing, not developer facing.
- Do not include reference to Roam Research (eg: This is a Roam Research extension), that is implied.
- Do not include build instructions (eg: `npm run build:roam`).
- update `# RoamJS Extension Base` to the name of the extension.
- update askdeepwiki badge to the name of the extension.
- update the description to be a < 250 character description of the extension. This should be enticing to new users to install the extension.
- add a `## Features` section to the README.md with a list of the features of the extension.

3. **Prefer**: Add React components under `src/components/` (see [autocomplete](https://github.com/RoamJS/autocomplete), [giphy](https://github.com/RoamJS/giphy) for examples).

4. **Secrets (for publish)** — in the forked repo, configure:
- `ROAMJS_RELEASE_TOKEN`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`
- `AWS_REGION`, `ROAMJS_PROXY` (vars)

## Scripts

- `npm start` — samepage dev (local development)
- `npm run build:roam` — build for Roam (dry run; CI runs `npx samepage build`)

## License

MIT
- Automatically reload all developer extensions once after this extension loads.
- Set the delay in seconds, including decimals such as 2.5.
- Choose whether to show a toast, its intent, and its message.
- Prevent repeated automatic reloads when this extension reloads itself.
175 changes: 175 additions & 0 deletions e2e/roam-proof.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import path from "node:path";
import {
openRoamDepotSettings,
removeExistingDeveloperExtensions,
} from "../../../.agents/skills/roamjs-load-extension/scripts/load-plugin.mjs";

const tab = (page) =>
page.getByRole("tab", { name: /^Dev on Load(?: \(dev\))?$/ });
const showToast = (page) =>
page.locator('.rm-modal-dialog--settings input[type="checkbox"]:visible');
const toast = (page, message) =>
page.locator(".bp3-toast").filter({ hasText: message });
const beginFreshLoad = async (page) => {
await page.evaluate(async () => {
window.__reloadProof.calls = [];
window.__reloadProof.loads = [];
window.__roamjsDevOnLoadSession.attempted = false;
await window.__reloadProof.original();
});
await page.waitForFunction(() => window.__reloadProof.loads.length > 0);
};
const waitForReload = async (page) => {
await page.waitForFunction(
() => window.__reloadProof.calls.some((call) => call.completed),
undefined,
{ timeout: 30000 },
);
return page.evaluate(() => ({
calls: window.__reloadProof.calls,
loads: window.__reloadProof.loads,
}));
};

export default {
runtimeNames: ["dev-on-load"],
async run({ page, outDir }) {
await tab(page).click();
assert.equal(
await page.getByPlaceholder("5", { exact: true }).inputValue(),
"5",
);
assert.equal(
await page.getByText("Delay (milliseconds)", { exact: true }).count(),
0,
);
assert.equal(
await page
.getByPlaceholder("Developer extensions reloaded.", { exact: true })
.inputValue(),
"Developer extensions reloaded.",
);
assert.equal(await showToast(page).isChecked(), true);
await page.getByRole("button", { name: "success", exact: true }).waitFor();
await page.screenshot({ path: path.join(outDir, "settings.png") });

const defaults = await waitForReload(page);
assert.equal(defaults.calls.length, 1);
assert(
defaults.calls[0].started - defaults.loads[0] >= 4900,
"default waits five seconds",
);
const defaultToast = toast(page, "Developer extensions reloaded.");
await defaultToast.waitFor();
assert(
(await defaultToast.getAttribute("class")).includes("bp3-intent-success"),
);
await page.screenshot({ path: path.join(outDir, "default-toast.png") });
await page.waitForTimeout(6500);
assert.equal(
await page.evaluate(() => window.__reloadProof.calls.length),
1,
"self-reload must not loop",
);

await tab(page).click();
await page.getByPlaceholder("5", { exact: true }).fill("1.25");
await page
.getByPlaceholder("Developer extensions reloaded.", { exact: true })
.fill("Custom developer reload complete");
await page.getByRole("button", { name: "success", exact: true }).click();
await page.getByText("warning", { exact: true }).click();
await page.getByRole("button", { name: "warning", exact: true }).waitFor();
await page.screenshot({ path: path.join(outDir, "custom-settings.png") });
await beginFreshLoad(page);
const custom = await waitForReload(page);
assert.equal(custom.calls.length, 1);
const elapsedMs = custom.calls[0].started - custom.loads[0];
assert(
elapsedMs >= 1200 && elapsedMs < 3000,
`expected 1250ms delay, got ${elapsedMs}ms`,
);
const customToast = toast(page, "Custom developer reload complete");
await customToast.waitFor();
assert(
(await customToast.getAttribute("class")).includes("bp3-intent-warning"),
);
await page.screenshot({ path: path.join(outDir, "custom-toast.png") });
await page.waitForTimeout(5500);
assert.equal(
await page.evaluate(() => window.__reloadProof.calls.length),
1,
);

await tab(page).click();
assert.equal(
await page.getByPlaceholder("5", { exact: true }).inputValue(),
"1.25",
"seconds persist after reload",
);
await showToast(page)
.locator("..")
.locator(".bp3-control-indicator")
.click();
await page.waitForFunction(
() =>
document.querySelector(
'.rm-modal-dialog--settings label.rm-settings-panel__value input[type="checkbox"]',
)?.checked === false,
);
assert.equal(await showToast(page).isChecked(), false);
await beginFreshLoad(page);
const silent = await waitForReload(page);
await page.waitForTimeout(500);
assert.equal(silent.calls.length, 1);
assert.equal(await customToast.count(), 0, "toast disabled");
await tab(page).click();
assert.equal(
await showToast(page).isChecked(),
false,
"toast setting persists",
);
await page.screenshot({ path: path.join(outDir, "toast-disabled.png") });

return {
defaults,
custom,
customDelayMs: elapsedMs,
silent,
checks: [
"four native settings and defaults",
"real API reload",
"default success toast",
"no recursive reload",
"1250ms configured delay",
"custom warning toast",
"settings persist",
"toast disabled",
],
};
},
async cleanup({ page, result }) {
await page.evaluate(() => {
window.roamAlphaAPI.depot.reloadDeveloperExtensions =
window.__reloadProof.original;
});
await openRoamDepotSettings({ page, timeout: 30000 });
const removed = await removeExistingDeveloperExtensions({
page,
registrationName: "dev-on-load-proof",
timeout: 30000,
});
if (result.developerMode === "enabled") {
await page
.locator(".rm-extensions-installed__header button.bp3-icon-cog")
.click();
await page.getByText("Disable developer mode", { exact: true }).click();
}
return {
removed,
restoredDeveloperMode:
result.developerMode !== "enabled" ? "already enabled" : "disabled",
};
},
};
83 changes: 83 additions & 0 deletions e2e/run-proof.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import path from "node:path";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import { runExtensionTest } from "../../../.agents/skills/roamjs-load-extension/scripts/load-plugin.mjs";
import {
openRoamSession,
resolveRoamEnvironment,
} from "../../../.agents/skills/roamjs-playwright-session/scripts/roam-session.mjs";

const outArg = process.argv.indexOf("--out");
const outDir = path.resolve(
outArg >= 0 ? process.argv[outArg + 1] : "local/roam-proof",
);
const proofDir = path.join(outDir, "dev-on-load-proof");
await fs.cp(path.resolve("dist"), proofDir, { recursive: true });
const result = await runExtensionTest({
repo: proofDir,
out: outDir,
registrationName: "dev-on-load-proof",
envPath: process.env.ROAM_PLAYWRIGHT_ENV_PATH,
playwrightPackageRoot: process.env.PLAYWRIGHT_PACKAGE_ROOT,
runtimeNames: ["dev-on-load"],
testModule: path.resolve("e2e/roam-proof.mjs"),
sessionFactory: async (options) => {
const environment = await resolveRoamEnvironment({
cwd: process.cwd(),
envPath: options.envPath,
playwrightPackageRoot: options.playwrightPackageRoot,
});
const require = createRequire(
path.join(environment.playwrightPackageRoot, "package.json"),
);
const { chromium } = require(environment.playwrightPackageName);
const session = await openRoamSession({
...options,
chromium: {
launchPersistentContext: (profile, launchOptions) =>
chromium.launchPersistentContext(profile, {
...launchOptions,
recordVideo: {
dir: path.join(outDir, "video"),
size: { width: 1440, height: 1000 },
},
}),
},
});
await session.page.evaluate(() => {
const depot = window.roamAlphaAPI.depot;
if (typeof depot?.reloadDeveloperExtensions !== "function")
throw new Error("Live Roam reload API unavailable");
const original = depot.reloadDeveloperExtensions.bind(depot);
window.__reloadProof = { calls: [], loads: [], original };
document.body.addEventListener("roamjs:dev-on-load:loaded", () => {
window.__reloadProof.loads.push(performance.now());
});
depot.reloadDeveloperExtensions = async () => {
const call = { started: performance.now() };
window.__reloadProof.calls.push(call);
try {
const summary = await original();
call.completed = performance.now();
call.summary = summary;
return summary;
} catch (error) {
call.error = error.message;
throw error;
}
};
});
return {
...session,
playwrightPackageName: environment.playwrightPackageName,
playwrightPackageRoot: environment.playwrightPackageRoot,
};
},
});
console.log(
JSON.stringify(
{ ok: result.ok, test: result.test, resultPath: result.resultPath },
null,
2,
),
);
Loading
Loading